encrypt auth_state with MultiFernet

- MultFernet allows key rotation via `AUTH_STATE_KEY=secret2;secret1;secret0`
- Failure to decrypt results in cleared state
- Attempting to set auth_state without encryption is a hard failure
- Absent encryption, auth_state will always be None
This commit is contained in:
Min RK
2017-07-27 13:28:33 +02:00
parent 3d635816c9
commit 5714f56083
3 changed files with 178 additions and 40 deletions

View File

@@ -330,14 +330,15 @@ class BaseHandler(RequestHandler):
if authenticated: if authenticated:
username = authenticated['name'] username = authenticated['name']
auth_state = authenticated.get('auth_state') auth_state = authenticated.get('auth_state')
user = self.user_from_username(username)
# always set auth_state and commit,
# because there could be key-rotation or clearing of previous values
# going on.
user.auth_state = auth_state
self.db.commit()
self.set_login_cookie(user)
self.statsd.incr('login.success') self.statsd.incr('login.success')
self.statsd.timing('login.authenticate.success', auth_timer.ms) self.statsd.timing('login.authenticate.success', auth_timer.ms)
user = self.user_from_username(username)
if auth_state is not None:
user.auth_state = auth_state
self.db.commit()
self.set_login_cookie(user)
self.log.info("User logged in: %s", username) self.log.info("User logged in: %s", username)
return user return user
else: else:

View File

@@ -3,14 +3,18 @@
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import base64
from datetime import datetime from datetime import datetime
import enum import enum
import os import os
import json import json
from tornado import gen try:
import cryptography
except ImportError:
cryptography = None
from tornado.log import app_log from tornado.log import app_log
from tornado.httpclient import HTTPRequest, AsyncHTTPClient
from sqlalchemy.types import TypeDecorator, TEXT from sqlalchemy.types import TypeDecorator, TEXT
from sqlalchemy import ( from sqlalchemy import (
@@ -19,17 +23,17 @@ from sqlalchemy import (
DateTime, Enum DateTime, Enum
) )
from sqlalchemy.ext.declarative import declarative_base, declared_attr from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.orm import sessionmaker, relationship, backref from sqlalchemy.orm import sessionmaker, relationship
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from sqlalchemy.schema import Index, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.sql.expression import bindparam from sqlalchemy.sql.expression import bindparam
from sqlalchemy_utils.types.encrypted import EncryptedType from sqlalchemy_utils.types.encrypted import EncryptedType, FernetEngine
from sqlalchemy import create_engine, Table from sqlalchemy import create_engine, Table
from traitlets import HasTraits, List
from .utils import ( from .utils import (
random_port, url_path_join, wait_for_server, wait_for_http_server, random_port,
new_token, hash_token, compare_token, can_connect, new_token, hash_token, compare_token,
) )
@@ -38,7 +42,7 @@ class JSONDict(TypeDecorator):
Usage:: Usage::
JSONEncodedDict(255) JSONDict(255)
""" """
@@ -56,37 +60,84 @@ class JSONDict(TypeDecorator):
return value return value
class OptionalEncrypted(EncryptedType): def _fernet_key(key):
def __init__(self, type_in=None, key=None, engine=None, **kwargs): """Generate a Fernet key from a secret
try:
import cryptography Will always be 32 bytes (via sha256), url-safe base64-encoded,
except ImportError: per fernet spec.
# not installed, so no encryption! """
self.encrypted = False from cryptography.hazmat.primitives import hashes
print("Cryptography module not installed, auth_state will be disabled") from cryptography.hazmat.backends import default_backend
if isinstance(key, str):
key = key.encode()
digest = hashes.Hash(hashes.SHA256(), backend=default_backend())
digest.update(key)
return base64.urlsafe_b64encode(digest.finalize())
class MultiFernetEngine(FernetEngine):
"""Extend SQLAlchemy-Utils FernetEngine to use MultiFernet,
which supports key rotation.
"""
key_list = None
def _update_key(self, key):
if key == self.key_list:
return return
return self._initialize_engine(key)
if 'AUTH_STATE_ENCRYPTION_KEY' not in os.environ: def _initialize_engine(self, parent_class_key):
print("Encryption key not set, Auth state will be disabled") from cryptography.fernet import MultiFernet, Fernet
self.encrypted = False # key will be a *list* of keys
return self.key_list = parent_class_key
self.fernet = MultiFernet([Fernet(_fernet_key(key)) for key in self.key_list])
if key is None: class EncryptionUnavailable(Exception):
key = os.environ['AUTH_STATE_ENCRYPTION_KEY'] pass
super().__init__(type_in, key, engine, **kwargs)
class EncryptionConfig(HasTraits):
"""Encapsulate encryption configuration
Use via the encryption_config singleton below.
"""
key_list = List()
def _key_list_default(self):
if 'AUTH_STATE_KEY' not in os.environ:
return []
# key can be a ;-separated sequence for key rotation.
# First item in the list is used for encryption.
return os.environ['AUTH_STATE_KEY'].split(';')
@property
def available(self):
if not self.key_list:
return False
return cryptography is not None
encryption_config = EncryptionConfig()
class Encrypted(EncryptedType):
def __init__(self, type_in=None, key=None, **kwargs):
super().__init__(type_in, key=lambda : encryption_config.key_list, engine=MultiFernetEngine, **kwargs)
class CantEncrypt(TypeDecorator):
"""Use in place of Encrypted when Encrypted types can't even be instantiated (crypto unavailable)"""
def process_bind_param(self, value, dialect): def process_bind_param(self, value, dialect):
if not self.encrypted and value: if value is None:
# If we aren't encrypted and get a non-empty value, just set an empty value return value
# FIXME: Warn in logs here raise EncryptionUnavailable("cryptography library is unavailable")
return None
return super().process_bind_param(value, dialect)
def process_result_value(self, value, dialect): def process_result_value(self, value, dialect):
if not self.encrypted: if value is None:
# If we don't have encryption support, don't even try to decrypt it return value
return None raise EncryptionUnavailable("cryptography library is unavailable")
return super().process_result_value(value, dialect)
# if cryptography library is unavailable, use CantEncrypt
if cryptography is None:
Encrypted = CantEncrypt
Base = declarative_base() Base = declarative_base()
Base.log = app_log Base.log = app_log
@@ -180,7 +231,38 @@ class User(Base):
# We will need to figure something else out if/when we have multiple spawners per user # We will need to figure something else out if/when we have multiple spawners per user
state = Column(JSONDict) state = Column(JSONDict)
# Authenticators can store their state here: # Authenticators can store their state here:
auth_state = OptionalEncrypted(JSONDict) _auth_state = Column('auth_state', Encrypted(JSONDict))
# check for availability of encryption on a property
# to get better errors than raising in the TypeDecorator methods,
# which won't raise until `db.commit()`
@property
def auth_state(self):
# TODO: handle decryption failure
try:
value = self._auth_state
except Exception as e:
if encryption_config.available:
why = str(e)
else:
why = "encryption is unavailable"
app_log.warning("Failed to retrieve encrypted auth_state for %s because %s",
self.name, why)
return None
if value is not None and not encryption_config.available:
raise EncryptionUnavailable("auth_state requires cryptography library and AUTH_STATE_KEY")
return value
@auth_state.setter
def auth_state(self, value):
if value is None:
self._auth_state = value
return
if value is not None and not encryption_config.available:
raise EncryptionUnavailable("auth_state requires cryptography library and AUTH_STATE_KEY")
self._auth_state = value
# group mapping # group mapping
groups = relationship('Group', secondary='user_group_map', back_populates='users') groups = relationship('Group', secondary='user_group_map', back_populates='users')

View File

@@ -3,6 +3,9 @@
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import base64
import cryptography
import os
import socket import socket
import pytest import pytest
@@ -175,3 +178,55 @@ def test_groups(db):
db.commit() db.commit()
assert group.users == [user] assert group.users == [user]
assert user.groups == [group] assert user.groups == [group]
def test_auth_state(db):
user = orm.User(name='eve')
db.add(user)
db.commit()
# starts empty
assert user.auth_state is None
# can't set auth_state without keys
state = {'key': 'value'}
orm.encryption_config.key_list = []
with pytest.raises(orm.EncryptionUnavailable):
user.auth_state = state
db.commit()
assert user.auth_state is None
#
first_key = 'first-key'
second_key = 'second-key'
orm.encryption_config.key_list = [first_key]
user.auth_state = state
db.commit()
assert user.auth_state == state
# can't read auth_state without keys
orm.encryption_config.key_list = []
with pytest.raises(orm.EncryptionUnavailable):
print(user.auth_state)
# key rotation works
db.rollback()
orm.encryption_config.key_list = [second_key, first_key]
assert user.auth_state == state
user.auth_state = new_state = {'key': 'newvalue'}
db.commit()
orm.encryption_config.key_list = [first_key]
db.rollback()
# can't read anymore with new-key after encrypting with second-key
assert user.auth_state is None
user.auth_state = new_state
db.commit()
assert user.auth_state == new_state
orm.encryption_config.key_list = []
db.rollback()
assert user.auth_state is None