Skip to content

Commit 488507f

Browse files
authored
Merge pull request #29 from quantopian/add-crypto-factories
ENH: Add per-user crypto factories. BUG: Specify notebook<5 and iPy<6 in setup.
2 parents 11e23ff + ac96c0e commit 488507f

File tree

4 files changed

+161
-2
lines changed

4 files changed

+161
-2
lines changed

pgcontents/crypto.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
"""
22
Interface definition for encryption/decryption plugins for
3-
PostgresContentsManager.
3+
PostgresContentsManager, and implementations of the interface.
44
55
Encryption backends should raise pgcontents.error.CorruptedFile if they
66
encounter an input that they cannot decrypt.
77
"""
8+
import sys
9+
import base64
10+
11+
from cryptography.fernet import Fernet
12+
from cryptography.hazmat.backends import default_backend
13+
from cryptography.hazmat.primitives import hashes
14+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
15+
816
from .error import CorruptedFile
917

18+
if sys.version_info.major == 3:
19+
unicode = str
20+
1021

1122
class NoEncryption(object):
1223
"""
@@ -127,3 +138,91 @@ def decrypt(self, s):
127138
except CorruptedFile as e:
128139
errors.append(e)
129140
raise CorruptedFile(errors)
141+
142+
143+
def ascii_unicode_to_bytes(v):
144+
assert isinstance(v, unicode), "Expected unicode, got %s" % type(v)
145+
return v.encode('ascii')
146+
147+
148+
def derive_single_fernet_key(password, user_id):
149+
"""
150+
Convert a secret key and a user ID into an encryption key to use with a
151+
``cryptography.fernet.Fernet``.
152+
153+
Taken from
154+
https://cryptography.io/en/latest/fernet/#using-passwords-with-fernet
155+
156+
Parameters
157+
----------
158+
password : unicode
159+
ascii-encodable key to derive
160+
user_id : unicode
161+
ascii-encodable user_id to use as salt
162+
"""
163+
password = ascii_unicode_to_bytes(password)
164+
user_id = ascii_unicode_to_bytes(user_id)
165+
166+
kdf = PBKDF2HMAC(
167+
algorithm=hashes.SHA256(),
168+
length=32,
169+
salt=user_id,
170+
iterations=100000,
171+
backend=default_backend(),
172+
)
173+
return base64.urlsafe_b64encode(kdf.derive(password))
174+
175+
176+
def derive_fallback_fernet_keys(passwords, user_id):
177+
"""
178+
Derive a list of per-user Fernet keys from a list of master keys and a
179+
username.
180+
181+
If a None is encountered in ``passwords``, it is forwarded.
182+
183+
Parameters
184+
----------
185+
passwords : list[unicode]
186+
List of ascii-encodable keys to derive.
187+
user_id : unicode or None
188+
ascii-encodable user_id to use as salt
189+
"""
190+
# Normally I wouldn't advocate for these kinds of assertions, but we really
191+
# really really don't want to mess up deriving encryption keys.
192+
assert isinstance(passwords, (list, tuple)), \
193+
"Expected list or tuple of keys, got %s." % type(passwords)
194+
195+
def derive_single_allow_none(k):
196+
if k is None:
197+
return None
198+
return derive_single_fernet_key(k, user_id).decode('ascii')
199+
200+
return list(map(derive_single_allow_none, passwords))
201+
202+
203+
def no_password_crypto_factory():
204+
"""
205+
Create and return a function suitable for passing as a crypto_factory to
206+
``pgcontents.utils.sync.reencrypt_all_users``
207+
208+
The factory here always returns NoEncryption(). This is useful when passed
209+
as ``old_crypto_factory`` to a database that hasn't yet been encrypted.
210+
"""
211+
def factory(user_id):
212+
return NoEncryption()
213+
return factory
214+
215+
216+
def single_password_crypto_factory(password):
217+
"""
218+
Create and return a function suitable for passing as a crypto_factory to
219+
``pgcontents.utils.sync.reencrypt_all_users``
220+
221+
The factory here returns a ``FernetEncryption`` that uses a key derived
222+
from ``password`` and salted with the supplied user_id.
223+
"""
224+
def factory(user_id):
225+
return FernetEncryption(
226+
Fernet(derive_single_fernet_key(password, user_id))
227+
)
228+
return factory
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
Tests for notebook encryption utilities.
3+
"""
4+
from cryptography.fernet import Fernet
5+
6+
from ..crypto import (
7+
derive_fallback_fernet_keys,
8+
FallbackCrypto,
9+
FernetEncryption,
10+
NoEncryption,
11+
single_password_crypto_factory,
12+
)
13+
14+
15+
def test_fernet_derivation():
16+
pws = [u'currentpassword', u'oldpassword', None]
17+
18+
# This must be Unicode, so we use the `u` prefix to support py2.
19+
user_id = u'4e322fa200fffd0001000001'
20+
21+
current_crypto = single_password_crypto_factory(pws[0])(user_id)
22+
old_crypto = single_password_crypto_factory(pws[1])(user_id)
23+
24+
def make_single_key_crypto(key):
25+
if key is None:
26+
return NoEncryption()
27+
return FernetEncryption(Fernet(key.encode('ascii')))
28+
29+
multi_fernet_crypto = FallbackCrypto(
30+
[make_single_key_crypto(k)
31+
for k in derive_fallback_fernet_keys(pws, user_id)]
32+
)
33+
34+
data = b'ayy lmao'
35+
36+
# Data encrypted with the current key.
37+
encrypted_data_current = current_crypto.encrypt(data)
38+
assert encrypted_data_current != data
39+
assert current_crypto.decrypt(encrypted_data_current) == data
40+
41+
# Data encrypted with the old key.
42+
encrypted_data_old = old_crypto.encrypt(data)
43+
assert encrypted_data_current != data
44+
assert old_crypto.decrypt(encrypted_data_old) == data
45+
46+
# The single fernet with the first key should be able to decrypt the
47+
# multi-fernet's encrypted data.
48+
49+
assert current_crypto.decrypt(multi_fernet_crypto.encrypt(data)) == data
50+
51+
# Multi should be able decrypt anything encrypted with either key.
52+
assert multi_fernet_crypto.decrypt(encrypted_data_current) == data
53+
assert multi_fernet_crypto.decrypt(encrypted_data_old) == data
54+
55+
# Unencrypted data should be returned unchanged.
56+
assert multi_fernet_crypto.decrypt(data) == data

pgcontents/utils/ipycompat.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@
4646
Unicode,
4747
)
4848
else:
49+
import notebook
50+
if notebook.version_info[0] >= 5:
51+
raise ImportError("Notebook versions 5 and up are not supported.")
52+
4953
from traitlets.config import Config
5054
from notebook.services.contents.checkpoints import (
5155
Checkpoints,

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def main():
4848
extras_require={
4949
'test': test_reqs,
5050
'ipy3': ['ipython[test,notebook]<4.0'],
51-
'ipy4': ['notebook[test]>=4.0'],
51+
'ipy4': ['ipython<6.0', 'notebook[test]>=4.0,<5.0'],
5252
},
5353
scripts=[
5454
'bin/pgcontents',

0 commit comments

Comments
 (0)