From 73706632d56ada0b882c9d34a6190945996c85a0 Mon Sep 17 00:00:00 2001 From: MinRK Date: Sun, 26 Oct 2014 17:27:47 -0700 Subject: [PATCH] database security - add files for cookie and database secrets - store cookie secret on disk, instead of in database - encrypt auth tokens with EncryptedType --- jupyterhub/app.py | 79 ++++++++++++++++++++++++++++++++---- jupyterhub/orm.py | 15 +++++-- jupyterhub/tests/test_orm.py | 1 - requirements.txt | 2 + 4 files changed, 85 insertions(+), 12 deletions(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 0ce8cfca..b400117b 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -4,6 +4,7 @@ # Copyright (c) IPython Development Team. # Distributed under the terms of the Modified BSD License. +import binascii import io import logging import os @@ -16,6 +17,7 @@ except NameError: # py3 raw_input = input +from six import text_type from jinja2 import Environment, FileSystemLoader from sqlalchemy.exc import OperationalError @@ -28,7 +30,7 @@ from tornado import gen, web from IPython.utils.traitlets import ( Unicode, Integer, Dict, TraitError, List, Bool, Any, - Type, Set, Instance, + Type, Set, Instance, Bytes, ) from IPython.config import Application, catch_config_error @@ -39,7 +41,7 @@ from . import handlers, apihandlers from . import orm from ._data import DATA_FILES_PATH from .utils import ( - url_path_join, random_hex, TimeoutError, + url_path_join, TimeoutError, ISO8601_ms, ISO8601_s, getuser_unicode, ) # classes for config @@ -69,6 +71,8 @@ flags = { ), } +SECRET_BYTES = 2048 # the number of bytes to use when generating new secrets + class JupyterHubApp(Application): """An Application for starting a Multi-User Jupyter Notebook server.""" @@ -203,14 +207,27 @@ class JupyterHubApp(Application): if newnew != new: self.hub_prefix = newnew - cookie_secret = Unicode(config=True, + cookie_secret = Bytes(config=True, env='JPY_COOKIE_SECRET', help="""The cookie secret to use to encrypt cookies. Loaded from the JPY_COOKIE_SECRET env variable by default. """ ) - def _cookie_secret_default(self): - return os.environ.get('JPY_COOKIE_SECRET', random_hex(64)) + + cookie_secret_file = Unicode('jupyterhub_cookie_secret', config=True, + help="""File in which to store the cookie secret.""" + ) + + db_secret = Bytes(config=True, env='JPY_DB_SECRET', + help="""The database secret to use to encrypt sensitive information in the database. + + Loaded from the JPY_DB_SECRET env variable by default. + """ + ) + + db_secret_file = Unicode('jupyterhub_db_secret', config=True, + help="""File in which to store the database secret.""" + ) authenticator_class = Type(PAMAuthenticator, Authenticator, config=True, @@ -360,11 +377,59 @@ class JupyterHubApp(Application): if os.path.exists(path) and not os.access(path, os.W_OK): self.log.error("%s cannot edit %s", user, path) + def init_secrets(self): + traits = self.traits() + for key in ('cookie', 'db'): + trait_name = '{}_secret'.format(key) + env_name = traits[trait_name].get_metadata('env') + file_attr_name = '{}_secret_file'.format(key) + secret_file = os.path.abspath( + os.path.expanduser(getattr(self, file_attr_name)) + ) + secret = getattr(self, trait_name) + secret_from = 'config' + # load priority: 1. config, 2. env, 3. file + if not secret and os.environ.get(env_name): + secret_from = 'env' + self.log.info("Loading %s from env[%s]", trait_name, env_name) + secret = binascii.a2b_hex(os.environ[env_name]) + if not secret and os.path.exists(secret_file): + secret_from = 'file' + perm = os.stat(secret_file).st_mode + if perm & 0o077: + self.log.error("Bad permissions on %s", secret_file) + else: + self.log.info("Loading %s from %s", trait_name, secret_file) + with io.open(secret_file) as f: + b64_secret = f.read() + try: + secret = binascii.a2b_base64(b64_secret) + except Exception as e: + self.log.error("%s does not contain b64 key: %s", secret_file, e) + if not secret: + secret_from = 'new' + self.log.debug("Generating new %s", trait_name) + secret = os.urandom(SECRET_BYTES) + + if secret_file and secret_from == 'new': + # if we generated a new secret, store it in the secret_file + self.log.info("Writing %s to %s", trait_name, secret_file) + b64_secret = text_type(binascii.b2a_base64(secret)) + with io.open(secret_file, 'w', encoding='utf8') as f: + f.write(b64_secret) + try: + os.chmod(secret_file, 0o600) + except OSError: + self.log.warn("Failed to set permissions on %s", secret_file) + # store the loaded trait value + setattr(self, trait_name, secret) + def init_db(self): """Create the database connection""" self.log.debug("Connecting to db: %s", self.db_url) try: self.db = orm.new_session(self.db_url, reset=self.reset_db, echo=self.debug_db, + crypto_key=self.db_secret, **self.db_kwargs ) except OperationalError as e: @@ -383,7 +448,6 @@ class JupyterHubApp(Application): ip=self.hub_ip, port=self.hub_port, base_url=self.hub_prefix, - cookie_secret=self.cookie_secret, cookie_name=u'jupyter-hub-token', ) ) @@ -596,7 +660,7 @@ class JupyterHubApp(Application): authenticator=self.authenticator, spawner_class=self.spawner_class, base_url=self.base_url, - cookie_secret=self.hub.server.cookie_secret, + cookie_secret=self.cookie_secret, login_url=login_url, logout_url=logout_url, static_path=os.path.join(self.data_files_path, 'static'), @@ -628,6 +692,7 @@ class JupyterHubApp(Application): self.init_logging() self.write_pid_file() self.init_ports() + self.init_secrets() self.init_db() self.init_hub() self.init_proxy() diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 5abbbfd8..32914d7d 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -3,6 +3,7 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +from binascii import b2a_hex from datetime import datetime import errno import json @@ -25,6 +26,7 @@ from sqlalchemy.ext.declarative import declarative_base, declared_attr from sqlalchemy.orm import sessionmaker, relationship, backref from sqlalchemy.pool import StaticPool from sqlalchemy import create_engine +from sqlalchemy_utils.types import EncryptedType from .utils import random_port, url_path_join, wait_for_server, wait_for_http_server @@ -74,7 +76,6 @@ class Server(Base): ip = Column(Unicode, default=u'localhost') port = Column(Integer, default=random_port) base_url = Column(Unicode, default=u'/') - cookie_secret = Column(Unicode, default=u'') cookie_name = Column(Unicode, default=u'cookie') def __repr__(self): @@ -124,7 +125,7 @@ class Proxy(Base): """ __tablename__ = 'proxies' id = Column(Integer, primary_key=True) - auth_token = Column(Unicode, default=new_token) + auth_token = Column(EncryptedType(Unicode, key=b''), default=new_token) _public_server_id = Column(Integer, ForeignKey('servers.id')) public_server = relationship(Server, primaryjoin=_public_server_id == Server.id) _api_server_id = Column(Integer, ForeignKey('servers.id')) @@ -350,7 +351,7 @@ class User(Base): class Token(object): """Mixin for token tables, since we have two""" - token = Column(String, primary_key=True) + token = Column(EncryptedType(Unicode, key=b''), primary_key=True) @declared_attr def user_id(cls): return Column(Integer, ForeignKey('users.id')) @@ -381,7 +382,7 @@ class CookieToken(Token, Base): __tablename__ = 'cookie_tokens' -def new_session(url="sqlite:///:memory:", reset=False, **kwargs): +def new_session(url="sqlite:///:memory:", reset=False, crypto_key=None, **kwargs): """Create a new session at url""" if url.startswith('sqlite'): kwargs.setdefault('connect_args', {'check_same_thread': False}) @@ -391,6 +392,12 @@ def new_session(url="sqlite:///:memory:", reset=False, **kwargs): session = Session() if reset: Base.metadata.drop_all(engine) + # configure encryption key + if crypto_key: + for table in Base.metadata.tables.values(): + for column in table.columns.values(): + if isinstance(column.type, EncryptedType): + column.type.key = crypto_key Base.metadata.create_all(engine) return session diff --git a/jupyterhub/tests/test_orm.py b/jupyterhub/tests/test_orm.py index c2c29113..b32cb5ee 100644 --- a/jupyterhub/tests/test_orm.py +++ b/jupyterhub/tests/test_orm.py @@ -21,7 +21,6 @@ def test_server(db): assert server.proto == 'http' assert isinstance(server.port, int) assert isinstance(server.cookie_name, unicode) - assert isinstance(server.cookie_secret, unicode) assert server.url == 'http://localhost:%i/' % server.port diff --git a/requirements.txt b/requirements.txt index 063f082d..c28867f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,7 @@ tornado>=3.2 jinja2 simplepam sqlalchemy +sqlalchemy-utils +cryptography requests six