Skip to content

Commit 0a1d777

Browse files
committed
PYTHON-1942 Implement prose tests for External Key Vault
1 parent 72c7772 commit 0a1d777

File tree

5 files changed

+187
-23
lines changed

5 files changed

+187
-23
lines changed

pymongo/encryption.py

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
"""Client side encryption."""
1616

17+
import functools
1718
import subprocess
1819
import uuid
1920
import weakref
@@ -30,8 +31,9 @@
3031
MongoCryptCallback = object
3132

3233
from bson import _bson_to_dict, _dict_to_bson, decode, encode
33-
from bson.binary import STANDARD, Binary
3434
from bson.codec_options import CodecOptions
35+
from bson.binary import STANDARD, Binary
36+
from bson.errors import BSONError
3537
from bson.raw_bson import (DEFAULT_RAW_BSON_OPTIONS,
3638
RawBSONDocument,
3739
_inflate_bson)
@@ -56,6 +58,22 @@
5658
uuid_representation=STANDARD)
5759

5860

61+
def _wrap_encryption_errors(encryption_func=None):
62+
"""Decorator to wrap encryption related errors with EncryptionError."""
63+
@functools.wraps(encryption_func)
64+
def wrap_encryption_errors(*args, **kwargs):
65+
try:
66+
return encryption_func(*args, **kwargs)
67+
except BSONError:
68+
# BSON encoding/decoding errors are unrelated to encryption so
69+
# we should propagate them unchanged.
70+
raise
71+
except Exception as exc:
72+
raise EncryptionError(exc)
73+
74+
return wrap_encryption_errors
75+
76+
5977
class _EncryptionIO(MongoCryptCallback):
6078
def __init__(self, client, key_vault_coll, mongocryptd_client, opts):
6179
"""Internal class to perform I/O on behalf of pymongocrypt."""
@@ -85,14 +103,11 @@ def kms_request(self, kms_context):
85103
opts = PoolOptions(connect_timeout=_KMS_CONNECT_TIMEOUT,
86104
socket_timeout=_KMS_CONNECT_TIMEOUT,
87105
ssl_context=ctx)
88-
try:
89-
with _configured_socket((endpoint, _HTTPS_PORT), opts) as conn:
90-
conn.sendall(message)
91-
while kms_context.bytes_needed > 0:
92-
data = conn.recv(kms_context.bytes_needed)
93-
kms_context.feed(data)
94-
except Exception as exc:
95-
raise MongoCryptError(str(exc))
106+
with _configured_socket((endpoint, _HTTPS_PORT), opts) as conn:
107+
conn.sendall(message)
108+
while kms_context.bytes_needed > 0:
109+
data = conn.recv(kms_context.bytes_needed)
110+
kms_context.feed(data)
96111

97112
def collection_info(self, database, filter):
98113
"""Get the collection info for a namespace.
@@ -222,6 +237,7 @@ def __init__(self, io_callbacks, opts):
222237
opts._kms_providers, schema_map))
223238
self._bypass_auto_encryption = opts._bypass_auto_encryption
224239

240+
@_wrap_encryption_errors
225241
def encrypt(self, database, cmd, check_keys, codec_options):
226242
"""Encrypt a MongoDB command.
227243
@@ -237,16 +253,14 @@ def encrypt(self, database, cmd, check_keys, codec_options):
237253
# Workaround for $clusterTime which is incompatible with check_keys.
238254
cluster_time = check_keys and cmd.pop('$clusterTime', None)
239255
encoded_cmd = _dict_to_bson(cmd, check_keys, codec_options)
240-
try:
241-
encrypted_cmd = self._auto_encrypter.encrypt(database, encoded_cmd)
242-
except MongoCryptError as exc:
243-
raise EncryptionError(exc)
256+
encrypted_cmd = self._auto_encrypter.encrypt(database, encoded_cmd)
244257
# TODO: PYTHON-1922 avoid decoding the encrypted_cmd.
245258
encrypt_cmd = _inflate_bson(encrypted_cmd, DEFAULT_RAW_BSON_OPTIONS)
246259
if cluster_time:
247260
encrypt_cmd['$clusterTime'] = cluster_time
248261
return encrypt_cmd
249262

263+
@_wrap_encryption_errors
250264
def decrypt(self, response):
251265
"""Decrypt a MongoDB command response.
252266
@@ -256,10 +270,7 @@ def decrypt(self, response):
256270
:Returns:
257271
The decrypted command response.
258272
"""
259-
try:
260-
return self._auto_encrypter.decrypt(response)
261-
except MongoCryptError as exc:
262-
raise EncryptionError(exc)
273+
return self._auto_encrypter.decrypt(response)
263274

264275
def close(self):
265276
"""Cleanup resources."""
@@ -349,6 +360,7 @@ def __init__(self, kms_providers, key_vault_namespace, key_vault_client):
349360
self._encryption = ExplicitEncrypter(
350361
self._io_callbacks, MongoCryptOptions(kms_providers, None))
351362

363+
@_wrap_encryption_errors
352364
def create_data_key(self, kms_provider, master_key=None,
353365
key_alt_names=None):
354366
"""Create and insert a new data key into the key vault collection.
@@ -383,6 +395,7 @@ def create_data_key(self, kms_provider, master_key=None,
383395
return self._encryption.create_data_key(
384396
kms_provider, master_key=master_key, key_alt_names=key_alt_names)
385397

398+
@_wrap_encryption_errors
386399
def encrypt(self, value, algorithm, key_id=None, key_alt_name=None):
387400
"""Encrypt a BSON value with a given key and algorithm.
388401
@@ -410,6 +423,14 @@ def encrypt(self, value, algorithm, key_id=None, key_alt_name=None):
410423
doc, algorithm, key_id=raw_key_id, key_alt_name=key_alt_name)
411424
return decode(encrypted_doc)['v']
412425

426+
@_wrap_encryption_errors
427+
def _decrypt(self, value):
428+
"""Internal decrypt helper."""
429+
doc = encode({'v': value})
430+
decrypted_doc = self._encryption.decrypt(doc)
431+
# TODO: Add a required codec_options argument for decoding?
432+
return decode(decrypted_doc, codec_options=_DATA_KEY_OPTS)['v']
433+
413434
def decrypt(self, value):
414435
"""Decrypt an encrypted value.
415436
@@ -423,10 +444,8 @@ def decrypt(self, value):
423444
if not (isinstance(value, Binary) and value.subtype == 6):
424445
raise TypeError(
425446
'value to decrypt must be a bson.binary.Binary with subtype 6')
426-
doc = encode({'v': value})
427-
decrypted_doc = self._encryption.decrypt(doc)
428-
# TODO: Add a required codec_options argument for decoding?
429-
return decode(decrypted_doc, codec_options=_DATA_KEY_OPTS)['v']
447+
448+
return self._decrypt(value)
430449

431450
def close(self):
432451
"""Release resources."""

pymongo/errors.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,20 @@ class DocumentTooLarge(InvalidDocument):
249249
pass
250250

251251

252-
class EncryptionError(OperationFailure):
252+
class EncryptionError(PyMongoError):
253253
"""Raised when encryption or decryption fails.
254254
255+
This error always wraps another exception which can be retrieved via the
256+
:attr:`cause` property.
257+
255258
.. versionadded:: 3.9
256259
"""
260+
261+
def __init__(self, cause):
262+
super(EncryptionError, self).__init__(str(cause))
263+
self.__cause = cause
264+
265+
@property
266+
def cause(self):
267+
"""The exception that caused this encryption or decryption error."""
268+
return self.__cause
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"status": {
3+
"$numberInt": "1"
4+
},
5+
"_id": {
6+
"$binary": {
7+
"base64": "LOCALAAAAAAAAAAAAAAAAA==",
8+
"subType": "04"
9+
}
10+
},
11+
"masterKey": {
12+
"provider": "local"
13+
},
14+
"updateDate": {
15+
"$date": {
16+
"$numberLong": "1557827033449"
17+
}
18+
},
19+
"keyMaterial": {
20+
"$binary": {
21+
"base64": "Ce9HSz/HKKGkIt4uyy+jDuKGA+rLC2cycykMo6vc8jXxqa1UVDYHWq1r+vZKbnnSRBfB981akzRKZCFpC05CTyFqDhXv6OnMjpG97OZEREGIsHEYiJkBW0jJJvfLLgeLsEpBzsro9FztGGXASxyxFRZFhXvHxyiLOKrdWfs7X1O/iK3pEoHMx6uSNSfUOgbebLfIqW7TO++iQS5g1xovXA==",
22+
"subType": "00"
23+
}
24+
},
25+
"creationDate": {
26+
"$date": {
27+
"$numberLong": "1557827033449"
28+
}
29+
},
30+
"keyAltNames": [ "local" ]
31+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"properties": {
3+
"encrypted": {
4+
"encrypt": {
5+
"keyId": [
6+
{
7+
"$binary": {
8+
"base64": "LOCALAAAAAAAAAAAAAAAAA==",
9+
"subType": "04"
10+
}
11+
}
12+
],
13+
"bsonType": "string",
14+
"algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic"
15+
}
16+
}
17+
},
18+
"bsonType": "object"
19+
}

test/test_encryption.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,14 @@
2727
from bson import BSON, json_util
2828
from bson.binary import STANDARD, Binary, UUID_SUBTYPE
2929
from bson.codec_options import CodecOptions
30+
from bson.errors import BSONError
3031
from bson.json_util import JSONOptions
3132
from bson.raw_bson import RawBSONDocument
3233
from bson.son import SON
3334

34-
from pymongo.errors import ConfigurationError
35+
from pymongo.errors import (ConfigurationError,
36+
EncryptionError,
37+
OperationFailure)
3538
from pymongo.encryption_options import AutoEncryptionOpts, _HAVE_PYMONGOCRYPT
3639
from pymongo.mongo_client import MongoClient
3740
from pymongo.write_concern import WriteConcern
@@ -231,6 +234,10 @@ def _test_auto_encrypt(self, opts):
231234
self.assertIsInstance(encrypted_doc['_id'], int)
232235
self.assertEncrypted(encrypted_doc['ssn'])
233236

237+
# Attempt to encrypt an unencodable object.
238+
with self.assertRaises(BSONError):
239+
encrypted_coll.insert_one({'unencodeable': object()})
240+
234241
def test_auto_encrypt(self):
235242
# Configure the encrypted field via jsonSchema.
236243
json_schema = json_data('custom', 'schema.json')
@@ -298,6 +305,19 @@ def test_validation(self):
298305
with self.assertRaisesRegex(TypeError, msg):
299306
client_encryption.decrypt(Binary(b'123'))
300307

308+
def test_bson_errors(self):
309+
client_encryption = ClientEncryption(
310+
KMS_PROVIDERS, 'admin.datakeys', client_context.client)
311+
self.addCleanup(client_encryption.close)
312+
313+
# Attempt to encrypt an unencodable object.
314+
unencodable_value = object()
315+
with self.assertRaises(BSONError):
316+
client_encryption.encrypt(
317+
unencodable_value, Algorithm.Deterministic,
318+
key_id=Binary(uuid.uuid4().bytes, UUID_SUBTYPE))
319+
320+
301321
# Spec tests
302322

303323
AWS_CREDS = {
@@ -426,6 +446,69 @@ def create_key_vault(vault, *data_keys):
426446
return vault
427447

428448

449+
class TestExternalKeyVault(EncryptionIntegrationTest):
450+
451+
@staticmethod
452+
def kms_providers():
453+
return {'local': {'key': LOCAL_MASTER_KEY}}
454+
455+
def _test_external_key_vault(self, with_external_key_vault):
456+
self.client.db.coll.drop()
457+
vault = create_key_vault(
458+
self.client.admin.datakeys,
459+
json_data('corpus', 'corpus-key-local.json'),
460+
json_data('corpus', 'corpus-key-aws.json'))
461+
self.addCleanup(vault.drop)
462+
463+
# Configure the encrypted field via the local schema_map option.
464+
schemas = {'db.coll': json_data('external', 'external-schema.json')}
465+
if with_external_key_vault:
466+
key_vault_client = rs_or_single_client(
467+
username='fake-user', password='fake-pwd')
468+
self.addCleanup(key_vault_client.close)
469+
else:
470+
key_vault_client = client_context.client
471+
opts = AutoEncryptionOpts(
472+
self.kms_providers(), 'admin.datakeys', schema_map=schemas,
473+
key_vault_client=key_vault_client)
474+
475+
client_encrypted = rs_or_single_client(
476+
auto_encryption_opts=opts, uuidRepresentation='standard')
477+
self.addCleanup(client_encrypted.close)
478+
479+
client_encryption = ClientEncryption(
480+
self.kms_providers(), 'admin.datakeys', key_vault_client)
481+
self.addCleanup(client_encryption.close)
482+
483+
if with_external_key_vault:
484+
# Authentication error.
485+
with self.assertRaises(EncryptionError) as ctx:
486+
client_encrypted.db.coll.insert_one({"encrypted": "test"})
487+
# AuthenticationFailed error.
488+
self.assertIsInstance(ctx.exception.cause, OperationFailure)
489+
self.assertEqual(ctx.exception.cause.code, 18)
490+
else:
491+
client_encrypted.db.coll.insert_one({"encrypted": "test"})
492+
493+
if with_external_key_vault:
494+
# Authentication error.
495+
with self.assertRaises(EncryptionError) as ctx:
496+
client_encryption.encrypt(
497+
"test", Algorithm.Deterministic, key_id=LOCAL_KEY_ID)
498+
# AuthenticationFailed error.
499+
self.assertIsInstance(ctx.exception.cause, OperationFailure)
500+
self.assertEqual(ctx.exception.cause.code, 18)
501+
else:
502+
client_encryption.encrypt(
503+
"test", Algorithm.Deterministic, key_id=LOCAL_KEY_ID)
504+
505+
def test_external_key_vault_1(self):
506+
self._test_external_key_vault(True)
507+
508+
def test_external_key_vault_2(self):
509+
self._test_external_key_vault(False)
510+
511+
429512
class TestCorpus(EncryptionIntegrationTest):
430513

431514
@classmethod

0 commit comments

Comments
 (0)