Skip to content

Commit e49c418

Browse files
PYTHON-2371 Add Azure and GCP support for CSFLE (#506)
1 parent a771021 commit e49c418

File tree

7 files changed

+292
-11
lines changed

7 files changed

+292
-11
lines changed

.evergreen/config.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -357,9 +357,14 @@ functions:
357357
working_dir: "src"
358358
script: |
359359
if [ -n "${test_encryption}" ]; then
360-
cat <<EOT > fle_aws_creds.sh
360+
cat <<EOT > fle_creds.sh
361361
export FLE_AWS_KEY="${fle_aws_key}"
362362
export FLE_AWS_SECRET="${fle_aws_secret}"
363+
export FLE_AZURE_CLIENTID="${fle_azure_clientid}"
364+
export FLE_AZURE_TENANTID="${fle_azure_tenantid}"
365+
export FLE_AZURE_CLIENTSECRET="${fle_azure_clientsecret}"
366+
export FLE_GCP_EMAIL="${fle_gcp_email}"
367+
export FLE_GCP_PRIVATEKEY="${fle_gcp_privatekey}"
363368
EOT
364369
fi
365370
- command: shell.exec
@@ -381,8 +386,8 @@ functions:
381386
if [ -n "${test_encryption}" ]; then
382387
# Disable xtrace (just in case it was accidentally set).
383388
set +x
384-
. ./fle_aws_creds.sh
385-
rm -f ./fle_aws_creds.sh
389+
. ./fle_creds.sh
390+
rm -f ./fle_creds.sh
386391
export LIBMONGOCRYPT_URL="${libmongocrypt_url}"
387392
export TEST_ENCRYPTION=1
388393
fi

pymongo/encryption.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -358,9 +358,21 @@ def __init__(self, kms_providers, key_vault_namespace, key_vault_client,
358358
- `aws`: Map with "accessKeyId" and "secretAccessKey" as strings.
359359
These are the AWS access key ID and AWS secret access key used
360360
to generate KMS messages.
361-
- `local`: Map with "key" as a 96-byte array or string. "key"
362-
is the master key used to encrypt/decrypt data keys. This key
363-
should be generated and stored as securely as possible.
361+
- `azure`: Map with "tenantId", "clientId", and "clientSecret" as
362+
strings. Additionally, "identityPlatformEndpoint" may also be
363+
specified as a string (defaults to 'login.microsoftonline.com').
364+
These are the Azure Active Directory credentials used to
365+
generate Azure Key Vault messages.
366+
- `gcp`: Map with "email" as a string and "privateKey"
367+
as `bytes` or a base64 encoded string (unicode on Python 2).
368+
Additionally, "endpoint" may also be specified as a string
369+
(defaults to 'oauth2.googleapis.com'). These are the
370+
credentials used to generate Google Cloud KMS messages.
371+
- `local`: Map with "key" as `bytes` (96 bytes in length) or
372+
a base64 encoded string (unicode on Python 2) which decodes
373+
to 96 bytes. "key" is the master key used to encrypt/decrypt
374+
data keys. This key should be generated and stored as securely
375+
as possible.
364376
365377
- `key_vault_namespace`: The namespace for the key vault collection.
366378
The key vault collection contains all data keys used for encryption
@@ -409,8 +421,10 @@ def create_data_key(self, kms_provider, master_key=None,
409421
"aws" and "local".
410422
- `master_key`: Identifies a KMS-specific key used to encrypt the
411423
new data key. If the kmsProvider is "local" the `master_key` is
412-
not applicable and may be omitted. If the `kms_provider` is "aws"
413-
it is required and has the following fields::
424+
not applicable and may be omitted.
425+
426+
If the `kms_provider` is "aws" it is required and has the
427+
following fields::
414428
415429
- `region` (string): Required. The AWS region, e.g. "us-east-1".
416430
- `key` (string): Required. The Amazon Resource Name (ARN) to
@@ -419,6 +433,26 @@ def create_data_key(self, kms_provider, master_key=None,
419433
requests to. May include port number, e.g.
420434
"kms.us-east-1.amazonaws.com:443".
421435
436+
If the `kms_provider` is "azure" it is required and has the
437+
following fields::
438+
439+
- `keyVaultEndpoint` (string): Required. Host with optional
440+
port, e.g. "example.vault.azure.net".
441+
- `keyName` (string): Required. Key name in the key vault.
442+
- `keyVersion` (string): Optional. Version of the key to use.
443+
444+
If the `kms_provider` is "gcp" it is required and has the
445+
following fields::
446+
447+
- `projectId` (string): Required. The Google cloud project ID.
448+
- `location` (string): Required. The GCP location, e.g. "us-east1".
449+
- `keyRing` (string): Required. Name of the key ring that contains
450+
the key to use.
451+
- `keyName` (string): Required. Name of the key to use.
452+
- `keyVersion` (string): Optional. Version of the key to use.
453+
- `endpoint` (string): Optional. Host with optional port.
454+
Defaults to "cloudkms.googleapis.com".
455+
422456
- `key_alt_names` (optional): An optional list of string alternate
423457
names used to reference a key. If a key is created with alternate
424458
names, then encryption may refer to the key by the unique alternate

pymongo/encryption_options.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,21 @@ def __init__(self, kms_providers, key_vault_namespace,
5959
- `aws`: Map with "accessKeyId" and "secretAccessKey" as strings.
6060
These are the AWS access key ID and AWS secret access key used
6161
to generate KMS messages.
62-
- `local`: Map with "key" as a 96-byte array or string. "key"
63-
is the master key used to encrypt/decrypt data keys. This key
64-
should be generated and stored as securely as possible.
62+
- `azure`: Map with "tenantId", "clientId", and "clientSecret" as
63+
strings. Additionally, "identityPlatformEndpoint" may also be
64+
specified as a string (defaults to 'login.microsoftonline.com').
65+
These are the Azure Active Directory credentials used to
66+
generate Azure Key Vault messages.
67+
- `gcp`: Map with "email" as a string and "privateKey"
68+
as `bytes` or a base64 encoded string (unicode on Python 2).
69+
Additionally, "endpoint" may also be specified as a string
70+
(defaults to 'oauth2.googleapis.com'). These are the
71+
credentials used to generate Google Cloud KMS messages.
72+
- `local`: Map with "key" as `bytes` (96 bytes in length) or
73+
a base64 encoded string (unicode on Python 2) which decodes
74+
to 96 bytes. "key" is the master key used to encrypt/decrypt
75+
data keys. This key should be generated and stored as securely
76+
as possible.
6577
6678
- `key_vault_namespace`: The namespace for the key vault collection.
6779
The key vault collection contains all data keys used for encryption
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"_id": {
3+
"$binary": {
4+
"base64": "As3URE1jRcyHOPjaLWHOXA==",
5+
"subType": "04"
6+
}
7+
},
8+
"keyMaterial": {
9+
"$binary": {
10+
"base64": "df6fFLZqBsZSnQz2SnTYWNBtznIHktVSDMaidAdL7yVVgxBJQ0DyPZUR2HDQB4hdYym3w4C+VGqzcyTZNJOXn6nJzpGrGlIQMcjv93HE4sP2d245ShQCi1nTkLmMaXN63E2fzltOY3jW7ojf5Z4+r8kxmzyfymmSRgo0w8AF7lUWvFhnBYoE4tE322L31vtAK3Zj8pTPvw8/TcUdMSI9Y669IIzxbMy5yMPmdzpnb8nceUv6/CJoeiLhbt5GgaHqIAv7tHFOY8ZX8ztowMLa3GeAjd9clvzraDTqrfMFYco/kDKAW5iPQQ+Xuy1fP8tyFp0ZwaL/7Ed2sc819j8FTQ==",
11+
"subType": "00"
12+
}
13+
},
14+
"creationDate": {
15+
"$date": {
16+
"$numberLong": "1601573901680"
17+
}
18+
},
19+
"updateDate": {
20+
"$date": {
21+
"$numberLong": "1601573901680"
22+
}
23+
},
24+
"status": {
25+
"$numberInt": "0"
26+
},
27+
"masterKey": {
28+
"provider": "azure",
29+
"keyVaultEndpoint": "key-vault-kevinalbs.vault.azure.net",
30+
"keyName": "test-key"
31+
}
32+
}
33+
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"db.coll": {
3+
"bsonType": "object",
4+
"properties": {
5+
"secret_azure": {
6+
"encrypt": {
7+
"keyId": [{
8+
"$binary": {
9+
"base64": "As3URE1jRcyHOPjaLWHOXA==",
10+
"subType": "04"
11+
}
12+
}],
13+
"algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic",
14+
"bsonType": "string"
15+
}
16+
},
17+
"secret_gcp": {
18+
"encrypt": {
19+
"keyId": [{
20+
"$binary": {
21+
"base64": "osU8SLxJRHONbl8Oh5o+eg==",
22+
"subType": "04"
23+
}
24+
}],
25+
"algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic",
26+
"bsonType": "string"
27+
}
28+
}
29+
}
30+
}
31+
}
32+
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"_id": {
3+
"$binary": {
4+
"base64": "osU8SLxJRHONbl8Oh5o+eg==",
5+
"subType": "04"
6+
}
7+
},
8+
"keyMaterial": {
9+
"$binary": {
10+
"base64": "CiQAg4LDql74hjYPZ957Z7YpCrD6yTVVXKegflJDstQ/xngTyx0SiQEAkWNo/fjPj6jMNSvEop07/29Fu72QHFDRYM3e/KFHfnMQjKzfxb1yX1dC6MbO5FZG/UNBkXlJgPqbHNVuizea3QC24kV5iOiEb4nTM7+RW+8TfVb6QerWWe6MjC+kNpj4LMVcc1lFfVDeGgpJLyMLNGitrjR16qH8qQTNbGNy0toTL69JUmgS8Q==",
11+
"subType": "00"
12+
}
13+
},
14+
"creationDate": {
15+
"$date": {
16+
"$numberLong": "1601574333107"
17+
}
18+
},
19+
"updateDate": {
20+
"$date": {
21+
"$numberLong": "1601574333107"
22+
}
23+
},
24+
"status": {
25+
"$numberInt": "0"
26+
},
27+
"masterKey": {
28+
"provider": "gcp",
29+
"projectId": "csfle-poc",
30+
"location": "global",
31+
"keyRing": "test",
32+
"keyName": "quickstart"
33+
}
34+
}
35+

test/test_encryption.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import traceback
2121
import socket
2222
import sys
23+
import textwrap
2324
import uuid
2425

2526
sys.path[0:0] = [""]
@@ -30,6 +31,7 @@
3031
STANDARD,
3132
UUID_SUBTYPE)
3233
from bson.codec_options import CodecOptions
34+
from bson.py3compat import _unicode
3335
from bson.errors import BSONError
3436
from bson.json_util import JSONOptions
3537
from bson.son import SON
@@ -52,6 +54,7 @@
5254
from test.utils import (TestCreator,
5355
camel_to_snake_args,
5456
OvertCommandListener,
57+
WhiteListEventListener,
5558
rs_or_single_client,
5659
wait_until)
5760
from test.utils_spec_runner import SpecRunner
@@ -1105,5 +1108,132 @@ def test_05_endpoint_invalid_host(self):
11051108
'aws', master_key=master_key)
11061109

11071110

1111+
class AzureGCPEncryptionTestMixin(object):
1112+
DEK = None
1113+
KMS_PROVIDER_MAP = None
1114+
KEYVAULT_DB = 'keyvault'
1115+
KEYVAULT_COLL = 'datakeys'
1116+
1117+
def setUp(self):
1118+
keyvault = self.client.get_database(
1119+
self.KEYVAULT_DB).get_collection(
1120+
self.KEYVAULT_COLL)
1121+
create_key_vault(keyvault, self.DEK)
1122+
1123+
def _test_explicit(self, expectation):
1124+
client_encryption = ClientEncryption(
1125+
self.KMS_PROVIDER_MAP,
1126+
'.'.join([self.KEYVAULT_DB, self.KEYVAULT_COLL]),
1127+
client_context.client,
1128+
OPTS)
1129+
self.addCleanup(client_encryption.close)
1130+
1131+
ciphertext = client_encryption.encrypt(
1132+
'test',
1133+
algorithm=Algorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic,
1134+
key_id=Binary.from_uuid(self.DEK['_id'], STANDARD))
1135+
1136+
self.assertEqual(bytes(ciphertext), base64.b64decode(expectation))
1137+
self.assertEqual(client_encryption.decrypt(ciphertext), 'test')
1138+
1139+
def _test_automatic(self, expectation_extjson, payload):
1140+
encrypted_db = "db"
1141+
encrypted_coll = "coll"
1142+
keyvault_namespace = '.'.join([self.KEYVAULT_DB, self.KEYVAULT_COLL])
1143+
1144+
encryption_opts = AutoEncryptionOpts(
1145+
self.KMS_PROVIDER_MAP,
1146+
keyvault_namespace,
1147+
schema_map=self.SCHEMA_MAP)
1148+
1149+
insert_listener = WhiteListEventListener('insert')
1150+
client = rs_or_single_client(
1151+
auto_encryption_opts=encryption_opts,
1152+
event_listeners=[insert_listener])
1153+
self.addCleanup(client.close)
1154+
1155+
coll = client.get_database(encrypted_db).get_collection(
1156+
encrypted_coll, codec_options=OPTS,
1157+
write_concern=WriteConcern("majority"))
1158+
coll.drop()
1159+
1160+
expected_document = json_util.loads(
1161+
expectation_extjson, json_options=JSON_OPTS)
1162+
1163+
coll.insert_one(payload)
1164+
event = insert_listener.results['started'][0]
1165+
inserted_doc = event.command['documents'][0]
1166+
1167+
for key, value in expected_document.items():
1168+
self.assertEqual(value, inserted_doc[key])
1169+
1170+
output_doc = coll.find_one({})
1171+
for key, value in payload.items():
1172+
self.assertEqual(output_doc[key], value)
1173+
1174+
1175+
AZURE_CREDS = {
1176+
'tenantId': os.environ.get('FLE_AZURE_TENANTID', ''),
1177+
'clientId': os.environ.get('FLE_AZURE_CLIENTID', ''),
1178+
'clientSecret': os.environ.get('FLE_AZURE_CLIENTSECRET', '')}
1179+
1180+
1181+
class TestAzureEncryption(AzureGCPEncryptionTestMixin,
1182+
EncryptionIntegrationTest):
1183+
@classmethod
1184+
@unittest.skipUnless(any(AZURE_CREDS.values()),
1185+
'Azure environment credentials are not set')
1186+
def setUpClass(cls):
1187+
cls.KMS_PROVIDER_MAP = {'azure': AZURE_CREDS}
1188+
cls.DEK = json_data(BASE, 'custom', 'azure-dek.json')
1189+
cls.SCHEMA_MAP = json_data(BASE, 'custom', 'azure-gcp-schema.json')
1190+
super(TestAzureEncryption, cls).setUpClass()
1191+
1192+
def test_explicit(self):
1193+
return self._test_explicit(
1194+
'AQLN1ERNY0XMhzj42i1hzlwC8/OSU9bHfaQRmmRF5l7d5ZpqJX13qF5zSyExo8N9c1b6uS/LoKrHNzcEMKNrkpi3jf2HiShTFRF0xi8AOD9yfw==')
1195+
1196+
def test_automatic(self):
1197+
expected_document_extjson = textwrap.dedent("""
1198+
{"secret_azure": {
1199+
"$binary": {
1200+
"base64": "AQLN1ERNY0XMhzj42i1hzlwC8/OSU9bHfaQRmmRF5l7d5ZpqJX13qF5zSyExo8N9c1b6uS/LoKrHNzcEMKNrkpi3jf2HiShTFRF0xi8AOD9yfw==",
1201+
"subType": "06"}
1202+
}}""")
1203+
return self._test_automatic(
1204+
expected_document_extjson, {"secret_azure": "test"})
1205+
1206+
1207+
GCP_CREDS = {
1208+
'email': os.environ.get('FLE_GCP_EMAIL', ''),
1209+
'privateKey': _unicode(os.environ.get('FLE_GCP_PRIVATEKEY', ''))}
1210+
1211+
1212+
class TestGCPEncryption(AzureGCPEncryptionTestMixin,
1213+
EncryptionIntegrationTest):
1214+
@classmethod
1215+
@unittest.skipUnless(any(GCP_CREDS.values()),
1216+
'GCP environment credentials are not set')
1217+
def setUpClass(cls):
1218+
cls.KMS_PROVIDER_MAP = {'gcp': GCP_CREDS}
1219+
cls.DEK = json_data(BASE, 'custom', 'gcp-dek.json')
1220+
cls.SCHEMA_MAP = json_data(BASE, 'custom', 'azure-gcp-schema.json')
1221+
super(TestGCPEncryption, cls).setUpClass()
1222+
1223+
def test_explicit(self):
1224+
return self._test_explicit(
1225+
'AaLFPEi8SURzjW5fDoeaPnoCGcOFAmFOPpn5584VPJJ8iXIgml3YDxMRZD9IWv5otyoft8fBzL1LsDEp0lTeB32cV1gOj0IYeAKHhGIleuHZtA==')
1226+
1227+
def test_automatic(self):
1228+
expected_document_extjson = textwrap.dedent("""
1229+
{"secret_gcp": {
1230+
"$binary": {
1231+
"base64": "AaLFPEi8SURzjW5fDoeaPnoCGcOFAmFOPpn5584VPJJ8iXIgml3YDxMRZD9IWv5otyoft8fBzL1LsDEp0lTeB32cV1gOj0IYeAKHhGIleuHZtA==",
1232+
"subType": "06"}
1233+
}}""")
1234+
return self._test_automatic(
1235+
expected_document_extjson, {"secret_gcp": "test"})
1236+
1237+
11081238
if __name__ == "__main__":
11091239
unittest.main()

0 commit comments

Comments
 (0)