Skip to content

feat: Python EncryptedClient impl and tests #1894

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
May 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Integration tests for encrypted interfaces.

These integration tests verify that encrypted boto3 interfaces behave as drop-in replacements for plaintext boto3 interfaces.

Each test runs with both a plaintext client and an encrypted client, using the same request parameters and expecting the same response.

This validates that encrypted clients expect the same input shapes as plaintext clients
and encrypted clients return the same output shapes as plaintext clients.

This guarantees that users can substitute encrypted interfaces without modifying their application logic.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
"""High-level helper class to provide an encrypting wrapper for boto3 DynamoDB clients."""
import pytest
from botocore.client import BaseClient
from mock import MagicMock

from aws_dbesdk_dynamodb.encrypted.client import (
EncryptedClient,
)
from aws_dbesdk_dynamodb.smithygenerated.aws_cryptography_dbencryptionsdk_dynamodb.models import (
DynamoDbTablesEncryptionConfig,
)

mock_boto3_dynamodb_client = MagicMock(__class__=BaseClient)
mock_tables_encryption_config = MagicMock(__class__=DynamoDbTablesEncryptionConfig)


def test_GIVEN_valid_inputs_WHEN_create_EncryptedClient_THEN_success():
# Given: Valid EncryptedClient inputs
# When: Create EncryptedClient
EncryptedClient(
client=mock_boto3_dynamodb_client,
encryption_config=mock_tables_encryption_config,
)
# Then: Success


def test_GIVEN_invalid_class_attribute_WHEN_getattr_THEN_raise_error():
# Create a mock with a specific spec that excludes our unknown attribute
mock_boto3_dynamodb_client = MagicMock(spec=["put_item", "get_item", "query", "scan"])
encrypted_client = EncryptedClient(
client=mock_boto3_dynamodb_client,
encryption_config=mock_tables_encryption_config,
)

# Then: AttributeError is raised
with pytest.raises(AttributeError):
# Given: Invalid class attribute: not_a_valid_attribute_on_EncryptedClient_nor_boto3_client
# When: getattr is called
encrypted_client.not_a_valid_attribute_on_EncryptedClient_nor_boto3_client()
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
"""Stub to allow relative imports of examples from tests."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
"""
Example for using an EncryptedClient to put and get an encrypted item.

Running this example requires access to the DDB Table whose name
is provided in the function arguments.
This table must be configured with the following
primary key configuration:
- Partition key is named "partition_key" with type (S)
- Sort key is named "sort_key" with type (N)
"""

import boto3
from aws_cryptographic_material_providers.mpl import AwsCryptographicMaterialProviders
from aws_cryptographic_material_providers.mpl.config import MaterialProvidersConfig
from aws_cryptographic_material_providers.mpl.models import (
CreateAwsKmsMrkMultiKeyringInput,
DBEAlgorithmSuiteId,
)
from aws_cryptographic_material_providers.mpl.references import IKeyring
from aws_dbesdk_dynamodb.encrypted.client import EncryptedClient
from aws_dbesdk_dynamodb.structures.dynamodb import (
DynamoDbTableEncryptionConfig,
DynamoDbTablesEncryptionConfig,
)
from aws_dbesdk_dynamodb.structures.structured_encryption import (
CryptoAction,
)


def encrypted_client_put_get_example(
kms_key_id: str,
dynamodb_table_name: str,
):
"""Use an EncryptedClient to put and get an encrypted item."""
# 1. Create a Keyring. This Keyring will be responsible for protecting the data keys that protect your data.
# For this example, we will create a AWS KMS Keyring with the AWS KMS Key we want to use.
# We will use the `CreateMrkMultiKeyring` method to create this keyring,
# as it will correctly handle both single region and Multi-Region KMS Keys.
mat_prov: AwsCryptographicMaterialProviders = AwsCryptographicMaterialProviders(config=MaterialProvidersConfig())
kms_mrk_multi_keyring_input: CreateAwsKmsMrkMultiKeyringInput = CreateAwsKmsMrkMultiKeyringInput(
generator=kms_key_id,
)
kms_mrk_multi_keyring: IKeyring = mat_prov.create_aws_kms_mrk_multi_keyring(input=kms_mrk_multi_keyring_input)

# 2. Configure which attributes are encrypted and/or signed when writing new items.
# For each attribute that may exist on the items we plan to write to our DynamoDbTable,
# we must explicitly configure how they should be treated during item encryption:
# - ENCRYPT_AND_SIGN: The attribute is encrypted and included in the signature
# - SIGN_ONLY: The attribute not encrypted, but is still included in the signature
# - DO_NOTHING: The attribute is not encrypted and not included in the signature
attribute_actions_on_encrypt = {
"partition_key": CryptoAction.SIGN_ONLY,
"sort_key": CryptoAction.SIGN_ONLY,
"attribute1": CryptoAction.ENCRYPT_AND_SIGN,
"attribute2": CryptoAction.SIGN_ONLY,
":attribute3": CryptoAction.DO_NOTHING,
}

# 3. Configure which attributes we expect to be included in the signature
# when reading items. There are two options for configuring this:
#
# - (Recommended) Configure `allowedUnsignedAttributesPrefix`:
# When defining your DynamoDb schema and deciding on attribute names,
# choose a distinguishing prefix (such as ":") for all attributes that
# you do not want to include in the signature.
# This has two main benefits:
# - It is easier to reason about the security and authenticity of data within your item
# when all unauthenticated data is easily distinguishable by their attribute name.
# - If you need to add new unauthenticated attributes in the future,
# you can easily make the corresponding update to your `attributeActionsOnEncrypt`
# and immediately start writing to that new attribute, without
# any other configuration update needed.
# Once you configure this field, it is not safe to update it.
#
# - Configure `allowedUnsignedAttributes`: You may also explicitly list
# a set of attributes that should be considered unauthenticated when encountered
# on read. Be careful if you use this configuration. Do not remove an attribute
# name from this configuration, even if you are no longer writing with that attribute,
# as old items may still include this attribute, and our configuration needs to know
# to continue to exclude this attribute from the signature scope.
# If you add new attribute names to this field, you must first deploy the update to this
# field to all readers in your host fleet before deploying the update to start writing
# with that new attribute.
#
# For this example, we have designed our DynamoDb table such that any attribute name with
# the ":" prefix should be considered unauthenticated.
unsignAttrPrefix: str = ":"

# 4. Create the DynamoDb Encryption configuration for the table we will be writing to.
table_configs = {}
table_config = DynamoDbTableEncryptionConfig(
logical_table_name=dynamodb_table_name,
partition_key_name="partition_key",
sort_key_name="sort_key",
attribute_actions_on_encrypt=attribute_actions_on_encrypt,
keyring=kms_mrk_multi_keyring,
allowed_unsigned_attribute_prefix=unsignAttrPrefix,
# Specifying an algorithm suite is not required,
# but is done here to demonstrate how to do so.
# We suggest using the
# `ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY_ECDSA_P384_SYMSIG_HMAC_SHA384` suite,
# which includes AES-GCM with key derivation, signing, and key commitment.
# This is also the default algorithm suite if one is not specified in this config.
# For more information on supported algorithm suites, see:
# https://docs.aws.amazon.com/database-encryption-sdk/latest/devguide/supported-algorithms.html
algorithm_suite_id=DBEAlgorithmSuiteId.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY_ECDSA_P384_SYMSIG_HMAC_SHA384,
)
table_configs[dynamodb_table_name] = table_config
tables_config = DynamoDbTablesEncryptionConfig(table_encryption_configs=table_configs)

# 5. Create the EncryptedClient
encrypted_client = EncryptedClient(
client=boto3.client("dynamodb"),
encryption_config=tables_config,
)

# 6. Put an item into our table using the above client.
# Before the item gets sent to DynamoDb, it will be encrypted
# client-side, according to our configuration.
item_to_encrypt = {
"partition_key": {"S": "BasicPutGetExample"},
"sort_key": {"N": "0"},
"attribute1": {"S": "encrypt and sign me!"},
"attribute2": {"S": "sign me!"},
":attribute3": {"S": "ignore me!"},
}

put_item_request = {
"TableName": dynamodb_table_name,
"Item": item_to_encrypt,
}

put_item_response = encrypted_client.put_item(**put_item_request)

# Demonstrate that PutItem succeeded
assert put_item_response["ResponseMetadata"]["HTTPStatusCode"] == 200

# 7. Get the item back from our table using the same client.
# The client will decrypt the item client-side, and return
# back the original item.
key_to_get = {"partition_key": {"S": "BasicPutGetExample"}, "sort_key": {"N": "0"}}

get_item_request = {"TableName": dynamodb_table_name, "Key": key_to_get}

get_item_response = encrypted_client.get_item(**get_item_request)

# Demonstrate that GetItem succeeded and returned the decrypted item
assert get_item_response["ResponseMetadata"]["HTTPStatusCode"] == 200
assert get_item_response["Item"] == item_to_encrypt

# 8. Clean up the item we put into the table by deleting it.
delete_item_request = {"TableName": dynamodb_table_name, "Key": key_to_get}
delete_item_response = encrypted_client.delete_item(**delete_item_request)

# Demonstrate that DeleteItem succeeded
assert delete_item_response["ResponseMetadata"]["HTTPStatusCode"] == 200
get_item_response = encrypted_client.get_item(**get_item_request)
assert "Item" not in get_item_response
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
"""Stub to allow relative imports of examples from tests."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
"""Test suite for the EncryptedClient example."""
import pytest

from ...src.basic_put_get_example.with_encrypted_client import encrypted_client_put_get_example

pytestmark = [pytest.mark.examples]


def test_encrypted_client_put_get_example():
"""Test function for encrypt and decrypt using the EncryptedClient example."""
test_kms_key_id = "arn:aws:kms:us-west-2:658956600833:key/b3537ef1-d8dc-4780-9f5a-55776cbb2f7f"
test_dynamodb_table_name = "DynamoDbEncryptionInterceptorTestTable"
encrypted_client_put_get_example(test_kms_key_id, test_dynamodb_table_name)
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import boto3
import aws_dbesdk_dynamodb_test_vectors.internaldafny.generated.CreateInterceptedDDBClient
import aws_cryptography_internal_dynamodb.internaldafny.extern
from aws_dbesdk_dynamodb.smithygenerated.aws_cryptography_dbencryptionsdk_dynamodb.dafny_to_smithy import aws_cryptography_dbencryptionsdk_dynamodb_DynamoDbTablesEncryptionConfig
from aws_dbesdk_dynamodb.encrypted.client import EncryptedClient
from smithy_dafny_standard_library.internaldafny.generated import Wrappers
from aws_dbesdk_dynamodb.smithygenerated.aws_cryptography_dbencryptionsdk_dynamodb.errors import _smithy_error_to_dafny_error
from aws_dbesdk_dynamodb_test_vectors.waiting_boto3_ddb_client import WaitingLocalDynamoClient

class default__:
@staticmethod
def CreateVanillaDDBClient():
try:
return aws_cryptography_internal_dynamodb.internaldafny.extern.Com_Amazonaws_Dynamodb.default__.DynamoDBClient(WaitingLocalDynamoClient())
except Exception as e:
return Wrappers.Result_Failure(_smithy_error_to_dafny_error(e))

@staticmethod
def CreateInterceptedDDBClient(dafny_encryption_config):
try:
native_encryption_config = aws_cryptography_dbencryptionsdk_dynamodb_DynamoDbTablesEncryptionConfig(dafny_encryption_config)
boto3_client = WaitingLocalDynamoClient()
encrypted_client = EncryptedClient(client = boto3_client, encryption_config = native_encryption_config)
return aws_cryptography_internal_dynamodb.internaldafny.extern.Com_Amazonaws_Dynamodb.default__.DynamoDBClient(encrypted_client)
except Exception as e:
return Wrappers.Result_Failure(_smithy_error_to_dafny_error(e))

aws_dbesdk_dynamodb_test_vectors.internaldafny.generated.CreateInterceptedDDBClient.default__ = default__
2 changes: 2 additions & 0 deletions TestVectors/runtimes/python/test/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
Empty file.
26 changes: 26 additions & 0 deletions TestVectors/runtimes/python/test/client/test_dafny_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
"""
Wrapper file for executing Dafny tests from pytest.
This allows us to import modules required by Dafny-generated tests
before executing Dafny-generated tests.
pytest will find and execute the `test_dafny` method below,
which will execute the `internaldafny_test_executor.py` file in the `dafny` directory.
"""

import sys

# Different from standard test_dafny_wrapper due to weird test structure.
test_dir = '/'.join(__file__.split("/")[:-2])

sys.path.append(test_dir + "/internaldafny/extern")
sys.path.append(test_dir + "/internaldafny/generated")

# Import extern to use an EncryptedClient as the wrapped DBESDK client.
import aws_dbesdk_dynamodb_test_vectors.internaldafny.extern.CreateInterceptedDDBClient
# Import extern to use the ItemEncryptor with DDB JSON-formatted items.
# (EncryptedClients use DDB JSON-formatted items by default.)
import aws_dbesdk_dynamodb_test_vectors.internaldafny.extern.CreateWrappedDynamoDbItemEncryptor

def test_dafny():
from ..internaldafny.generated import __main__
Loading