diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d9bca1f73..c02fb8017 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,25 @@ Changelog ********* +1.5.0 -- 2020-xx-xx +=================== + +Major Features +-------------- + +* Add `keyrings`_. +* Change one-step APIs to return a :class:`CryptoResult` rather than a tuple. + * Modified APIs: ``aws_encryption_sdk.encrypt`` and ``aws_encryption_sdk.decrypt``. + +.. note:: + + For backwards compatibility, + :class:`CryptoResult` also unpacks like a 2-member tuple. + This allows for backwards compatibility with the previous outputs + so this change should not break any existing consumers + unless you are specifically relying on the output being an instance of :class:`tuple`. + + 1.4.1 -- 2019-09-20 =================== @@ -193,3 +212,4 @@ Minor .. _pylint: https://www.pylint.org/ .. _flake8: http://flake8.pycqa.org/en/latest/ .. _doc8: https://launchpad.net/doc8 +.. _keyrings: https://docs.aws.amazon.com/encryption-sdk/latest/developer-guide/choose-keyring.html diff --git a/src/aws_encryption_sdk/__init__.py b/src/aws_encryption_sdk/__init__.py index 8cadd51b8..cc9b7b9f7 100644 --- a/src/aws_encryption_sdk/__init__.py +++ b/src/aws_encryption_sdk/__init__.py @@ -14,6 +14,9 @@ StreamDecryptor, StreamEncryptor, ) +from aws_encryption_sdk.structures import CryptoResult + +__all__ = ("encrypt", "decrypt", "stream") def encrypt(**kwargs): @@ -26,6 +29,13 @@ def encrypt(**kwargs): .. versionadded:: 1.5.0 The *keyring* parameter. + .. versionadded:: 1.5.0 + + For backwards compatibility, + the new :class:`CryptoResult` return value also unpacks like a 2-member tuple. + This allows for backwards compatibility with the previous outputs + so this change should not break any existing consumers. + .. code:: python >>> import aws_encryption_sdk @@ -67,12 +77,13 @@ def encrypt(**kwargs): :param algorithm: Algorithm to use for encryption :type algorithm: aws_encryption_sdk.identifiers.Algorithm :param int frame_length: Frame length in bytes - :returns: Tuple containing the encrypted ciphertext and the message header object - :rtype: tuple of bytes and :class:`aws_encryption_sdk.structures.MessageHeader` + :returns: Encrypted message, message metadata (header), and keyring trace + :rtype: CryptoResult """ with StreamEncryptor(**kwargs) as encryptor: ciphertext = encryptor.read() - return ciphertext, encryptor.header + + return CryptoResult(result=ciphertext, header=encryptor.header, keyring_trace=encryptor.keyring_trace) def decrypt(**kwargs): @@ -85,6 +96,13 @@ def decrypt(**kwargs): .. versionadded:: 1.5.0 The *keyring* parameter. + .. versionadded:: 1.5.0 + + For backwards compatibility, + the new :class:`CryptoResult` return value also unpacks like a 2-member tuple. + This allows for backwards compatibility with the previous outputs + so this change should not break any existing consumers. + .. code:: python >>> import aws_encryption_sdk @@ -117,12 +135,13 @@ def decrypt(**kwargs): :param int max_body_length: Maximum frame size (or content length for non-framed messages) in bytes to read from ciphertext message. - :returns: Tuple containing the decrypted plaintext and the message header object - :rtype: tuple of bytes and :class:`aws_encryption_sdk.structures.MessageHeader` + :returns: Decrypted plaintext, message metadata (header), and keyring trace + :rtype: CryptoResult """ with StreamDecryptor(**kwargs) as decryptor: plaintext = decryptor.read() - return plaintext, decryptor.header + + return CryptoResult(result=plaintext, header=decryptor.header, keyring_trace=decryptor.keyring_trace) def stream(**kwargs): @@ -182,6 +201,3 @@ def stream(**kwargs): return _stream_map[mode.lower()](**kwargs) except KeyError: raise ValueError("Unsupported mode: {}".format(mode)) - - -__all__ = ("encrypt", "decrypt", "stream") diff --git a/src/aws_encryption_sdk/materials_managers/__init__.py b/src/aws_encryption_sdk/materials_managers/__init__.py index e6e86c5cd..bd9ffb19e 100644 --- a/src/aws_encryption_sdk/materials_managers/__init__.py +++ b/src/aws_encryption_sdk/materials_managers/__init__.py @@ -25,7 +25,7 @@ from aws_encryption_sdk.structures import DataKey, EncryptedDataKey, KeyringTrace, RawDataKey try: # Python 3.5.0 and 3.5.1 have incompatible typing modules - from typing import Any, FrozenSet, Iterable, Tuple, Union # noqa pylint: disable=unused-import + from typing import Any, Iterable, Tuple, Union # noqa pylint: disable=unused-import except ImportError: # pragma: no cover # We only actually need these imports when running the mypy checks pass @@ -238,10 +238,10 @@ def __init__( @property def encrypted_data_keys(self): - # type: () -> FrozenSet[EncryptedDataKey] + # type: () -> Tuple[EncryptedDataKey] """Return a read-only version of the encrypted data keys. - :rtype: frozenset + :rtype: Tuple[EncryptedDataKey] """ return tuple(self._encrypted_data_keys) diff --git a/src/aws_encryption_sdk/streaming_client.py b/src/aws_encryption_sdk/streaming_client.py index d22fb7c70..ff549563d 100644 --- a/src/aws_encryption_sdk/streaming_client.py +++ b/src/aws_encryption_sdk/streaming_client.py @@ -137,6 +137,7 @@ class _EncryptionStream(io.IOBase): _message_prepped = None # type: bool source_stream = None _stream_length = None # type: int + keyring_trace = () def __new__(cls, **kwargs): """Perform necessary handling for _EncryptionStream instances that should be @@ -443,6 +444,7 @@ def _prep_message(self): self._encryption_materials = self.config.materials_manager.get_encryption_materials( request=encryption_materials_request ) + self.keyring_trace = self._encryption_materials.keyring_trace if self.config.algorithm is not None and self._encryption_materials.algorithm != self.config.algorithm: raise ActionNotAllowedError( @@ -779,6 +781,8 @@ def _read_header(self): encryption_context=header.encryption_context, ) decryption_materials = self.config.materials_manager.decrypt_materials(request=decrypt_materials_request) + self.keyring_trace = decryption_materials.keyring_trace + if decryption_materials.verification_key is None: self.verifier = None else: diff --git a/src/aws_encryption_sdk/structures.py b/src/aws_encryption_sdk/structures.py index ea9253b39..35eab24e6 100644 --- a/src/aws_encryption_sdk/structures.py +++ b/src/aws_encryption_sdk/structures.py @@ -20,6 +20,12 @@ from aws_encryption_sdk.identifiers import Algorithm, ContentType, KeyringTraceFlag, ObjectType, SerializationVersion from aws_encryption_sdk.internal.str_ops import to_bytes, to_str +try: # Python 3.5.0 and 3.5.1 have incompatible typing modules + from typing import Tuple # noqa pylint: disable=unused-import +except ImportError: # pragma: no cover + # We only actually need these imports when running the mypy checks + pass + @attr.s(hash=True) class MasterKeyInfo(object): @@ -107,8 +113,7 @@ class KeyringTrace(object): .. versionadded:: 1.5.0 :param MasterKeyInfo wrapping_key: Wrapping key used - :param flags: Actions performed - :type flags: set of :class:`KeyringTraceFlag` + :param Set[KeyringTraceFlag] flags: Actions performed """ wrapping_key = attr.ib(validator=instance_of(MasterKeyInfo)) @@ -120,19 +125,14 @@ class MessageHeader(object): # pylint: disable=too-many-instance-attributes """Deserialized message header object. - :param version: Message format version, per spec - :type version: SerializationVersion - :param type: Message content type, per spec - :type type: ObjectType - :param algorithm: Algorithm to use for encryption - :type algorithm: Algorithm + :param SerializationVersion version: Message format version, per spec + :param ObjectType type: Message content type, per spec + :param AlgorithmSuite algorithm: Algorithm to use for encryption :param bytes message_id: Message ID - :param dict encryption_context: Dictionary defining encryption context - :param encrypted_data_keys: Encrypted data keys - :type encrypted_data_keys: set of :class:`aws_encryption_sdk.structures.EncryptedDataKey` - :param content_type: Message content framing type (framed/non-framed) - :type content_type: ContentType - :param bytes content_aad_length: empty + :param Dict[str,str] encryption_context: Dictionary defining encryption context + :param Sequence[EncryptedDataKey] encrypted_data_keys: Encrypted data keys + :param ContentType content_type: Message content framing type (framed/non-framed) + :param int content_aad_length: empty :param int header_iv_length: Bytes in Initialization Vector value found in header :param int frame_length: Length of message frame in bytes """ @@ -152,3 +152,41 @@ class MessageHeader(object): content_aad_length = attr.ib(hash=True, validator=instance_of(six.integer_types)) header_iv_length = attr.ib(hash=True, validator=instance_of(six.integer_types)) frame_length = attr.ib(hash=True, validator=instance_of(six.integer_types)) + + +@attr.s +class CryptoResult(object): + """Result container for one-shot cryptographic API results. + + .. versionadded:: 1.5.0 + + .. note:: + + For backwards compatibility, + this container also unpacks like a 2-member tuple. + This allows for backwards compatibility with the previous outputs. + + :param bytes result: Binary results of the cryptographic operation + :param MessageHeader header: Encrypted message metadata + :param Tuple[KeyringTrace] keyring_trace: Keyring trace entries + """ + + result = attr.ib(validator=instance_of(bytes)) + header = attr.ib(validator=instance_of(MessageHeader)) + keyring_trace = attr.ib(validator=deep_iterable(member_validator=instance_of(KeyringTrace))) + + def __attrs_post_init__(self): + """Construct the inner tuple for backwards compatibility.""" + self._legacy_container = (self.result, self.header) + + def __len__(self): + """Emulate the inner tuple.""" + return self._legacy_container.__len__() + + def __iter__(self): + """Emulate the inner tuple.""" + return self._legacy_container.__iter__() + + def __getitem__(self, key): + """Emulate the inner tuple.""" + return self._legacy_container.__getitem__(key) diff --git a/test/functional/test_client.py b/test/functional/test_client.py index 3888479d4..ebe7e14d1 100644 --- a/test/functional/test_client.py +++ b/test/functional/test_client.py @@ -27,6 +27,7 @@ from aws_encryption_sdk.internal.formatting.encryption_context import serialize_encryption_context from aws_encryption_sdk.key_providers.base import MasterKeyProvider, MasterKeyProviderConfig from aws_encryption_sdk.key_providers.raw import RawMasterKeyProvider +from aws_encryption_sdk.keyrings.base import Keyring from aws_encryption_sdk.keyrings.raw import RawRSAKeyring from aws_encryption_sdk.materials_managers import DecryptionMaterialsRequest, EncryptionMaterialsRequest @@ -314,12 +315,20 @@ def test_encrypt_ciphertext_message(frame_length, algorithm, encryption_context) assert len(ciphertext) == results_length -def _raw_aes(): +def _raw_aes(include_mkp=True): for symmetric_algorithm in ( WrappingAlgorithm.AES_128_GCM_IV12_TAG16_NO_PADDING, WrappingAlgorithm.AES_192_GCM_IV12_TAG16_NO_PADDING, WrappingAlgorithm.AES_256_GCM_IV12_TAG16_NO_PADDING, ): + keyring = ephemeral_raw_aes_keyring(symmetric_algorithm) + yield pytest.param( + "keyring", keyring, "keyring", keyring, id="raw AES keyring -- {}".format(symmetric_algorithm.name) + ) + + if not include_mkp: + continue + yield pytest.param( "key_provider", build_fake_raw_key_provider(symmetric_algorithm, EncryptionKeyType.SYMMETRIC), @@ -327,10 +336,6 @@ def _raw_aes(): build_fake_raw_key_provider(symmetric_algorithm, EncryptionKeyType.SYMMETRIC), id="raw AES master key provider -- {}".format(symmetric_algorithm.name), ) - keyring = ephemeral_raw_aes_keyring(symmetric_algorithm) - yield pytest.param( - "keyring", keyring, "keyring", keyring, id="raw AES keyring -- {}".format(symmetric_algorithm.name) - ) mkp = ephemeral_raw_aes_master_key(wrapping_algorithm=symmetric_algorithm, key=keyring._wrapping_key) yield pytest.param( @@ -349,7 +354,7 @@ def _raw_aes(): ) -def _raw_rsa(include_pre_sha2=True, include_sha2=True): +def _raw_rsa(include_pre_sha2=True, include_sha2=True, include_mkp=True): wrapping_algorithms = [] if include_pre_sha2: wrapping_algorithms.extend([WrappingAlgorithm.RSA_PKCS1, WrappingAlgorithm.RSA_OAEP_SHA1_MGF1]) @@ -362,21 +367,6 @@ def _raw_rsa(include_pre_sha2=True, include_sha2=True): ] ) for wrapping_algorithm in wrapping_algorithms: - yield pytest.param( - "key_provider", - build_fake_raw_key_provider(wrapping_algorithm, EncryptionKeyType.PRIVATE), - "key_provider", - build_fake_raw_key_provider(wrapping_algorithm, EncryptionKeyType.PRIVATE), - id="raw RSA master key provider -- private encrypt, private decrypt -- {}".format(wrapping_algorithm.name), - ) - yield pytest.param( - "key_provider", - build_fake_raw_key_provider(wrapping_algorithm, EncryptionKeyType.PUBLIC), - "key_provider", - build_fake_raw_key_provider(wrapping_algorithm, EncryptionKeyType.PRIVATE), - id="raw RSA master key provider -- public encrypt, private decrypt -- {}".format(wrapping_algorithm.name), - ) - private_keyring = ephemeral_raw_rsa_keyring(wrapping_algorithm=wrapping_algorithm) public_keyring = RawRSAKeyring( key_namespace=private_keyring.key_namespace, @@ -398,8 +388,27 @@ def _raw_rsa(include_pre_sha2=True, include_sha2=True): private_keyring, id="raw RSA keyring -- public encrypt, private decrypt -- {}".format(wrapping_algorithm.name), ) + + if not include_mkp: + continue + private_mkp, public_mkp = raw_rsa_mkps_from_keyring(private_keyring) + yield pytest.param( + "key_provider", + build_fake_raw_key_provider(wrapping_algorithm, EncryptionKeyType.PRIVATE), + "key_provider", + build_fake_raw_key_provider(wrapping_algorithm, EncryptionKeyType.PRIVATE), + id="raw RSA master key provider -- private encrypt, private decrypt -- {}".format(wrapping_algorithm.name), + ) + yield pytest.param( + "key_provider", + build_fake_raw_key_provider(wrapping_algorithm, EncryptionKeyType.PUBLIC), + "key_provider", + build_fake_raw_key_provider(wrapping_algorithm, EncryptionKeyType.PRIVATE), + id="raw RSA master key provider -- public encrypt, private decrypt -- {}".format(wrapping_algorithm.name), + ) + yield pytest.param( "key_provider", private_mkp, @@ -452,17 +461,39 @@ def run_raw_provider_check( encrypt_kwargs = {encrypt_param_name: encrypting_provider} decrypt_kwargs = {decrypt_param_name: decrypting_provider} - ciphertext, _ = aws_encryption_sdk.encrypt( + encrypt_result = aws_encryption_sdk.encrypt( source=VALUES["plaintext_128"], encryption_context=VALUES["encryption_context"], frame_length=0, **encrypt_kwargs ) - plaintext, _ = aws_encryption_sdk.decrypt(source=ciphertext, **decrypt_kwargs) + decrypt_result = aws_encryption_sdk.decrypt(source=encrypt_result.result, **decrypt_kwargs) + + if isinstance(encrypting_provider, Keyring): + trace_entries = ( + entry + for entry in encrypt_result.keyring_trace + if ( + entry.wrapping_key.provider_id == encrypting_provider.key_namespace + and entry.wrapping_key.key_info == encrypting_provider.key_name + ) + ) + assert trace_entries - assert plaintext == VALUES["plaintext_128"] + assert decrypt_result.result == VALUES["plaintext_128"] assert_key_not_logged(encrypting_provider, log_capturer.text) + if isinstance(decrypting_provider, Keyring): + trace_entries = ( + entry + for entry in decrypt_result.keyring_trace + if ( + entry.wrapping_key.provider_id == decrypting_provider.key_namespace + and entry.wrapping_key.key_info == decrypting_provider.key_name + ) + ) + assert trace_entries + @pytest.mark.parametrize( "encrypt_param_name, encrypting_provider, decrypt_param_name, decrypting_provider", @@ -486,15 +517,9 @@ def test_encryption_cycle_raw_mkp_openssl_102_plus( run_raw_provider_check(caplog, encrypt_param_name, encrypting_provider, decrypt_param_name, decrypting_provider) -@pytest.mark.parametrize( - "frame_length, algorithm, encryption_context", - [ - [frame_length, algorithm_suite, encryption_context] - for frame_length in VALUES["frame_lengths"] - for algorithm_suite in Algorithm - for encryption_context in [{}, VALUES["encryption_context"]] - ], -) +@pytest.mark.parametrize("frame_length", VALUES["frame_lengths"]) +@pytest.mark.parametrize("algorithm", Algorithm) +@pytest.mark.parametrize("encryption_context", [{}, VALUES["encryption_context"]]) def test_encryption_cycle_oneshot_kms(frame_length, algorithm, encryption_context): key_provider = fake_kms_key_provider(algorithm.kdf_input_len) @@ -511,15 +536,9 @@ def test_encryption_cycle_oneshot_kms(frame_length, algorithm, encryption_contex assert plaintext == VALUES["plaintext_128"] * 10 -@pytest.mark.parametrize( - "frame_length, algorithm, encryption_context", - [ - [frame_length, algorithm_suite, encryption_context] - for frame_length in VALUES["frame_lengths"] - for algorithm_suite in Algorithm - for encryption_context in [{}, VALUES["encryption_context"]] - ], -) +@pytest.mark.parametrize("frame_length", VALUES["frame_lengths"]) +@pytest.mark.parametrize("algorithm", Algorithm) +@pytest.mark.parametrize("encryption_context", [{}, VALUES["encryption_context"]]) def test_encryption_cycle_stream_kms(frame_length, algorithm, encryption_context): key_provider = fake_kms_key_provider(algorithm.kdf_input_len) diff --git a/test/unit/test_client.py b/test/unit/test_client.py index 38dfff85a..d6b763b49 100644 --- a/test/unit/test_client.py +++ b/test/unit/test_client.py @@ -17,7 +17,12 @@ import aws_encryption_sdk import aws_encryption_sdk.internal.defaults +from .vectors import VALUES + pytestmark = [pytest.mark.unit, pytest.mark.local] +_CIPHERTEXT = b"CIPHERTEXT" +_PLAINTEXT = b"PLAINTEXT" +_HEADER = VALUES["deserialized_header_frame"] class TestAwsEncryptionSdk(object): @@ -27,16 +32,16 @@ def apply_fixtures(self): self.mock_stream_encryptor_patcher = patch("aws_encryption_sdk.StreamEncryptor") self.mock_stream_encryptor = self.mock_stream_encryptor_patcher.start() self.mock_stream_encryptor_instance = MagicMock() - self.mock_stream_encryptor_instance.read.return_value = sentinel.ciphertext - self.mock_stream_encryptor_instance.header = sentinel.header + self.mock_stream_encryptor_instance.read.return_value = _CIPHERTEXT + self.mock_stream_encryptor_instance.header = _HEADER self.mock_stream_encryptor.return_value = self.mock_stream_encryptor_instance self.mock_stream_encryptor_instance.__enter__.return_value = self.mock_stream_encryptor_instance # Set up StreamDecryptor patch self.mock_stream_decryptor_patcher = patch("aws_encryption_sdk.StreamDecryptor") self.mock_stream_decryptor = self.mock_stream_decryptor_patcher.start() self.mock_stream_decryptor_instance = MagicMock() - self.mock_stream_decryptor_instance.read.return_value = sentinel.plaintext - self.mock_stream_decryptor_instance.header = sentinel.header + self.mock_stream_decryptor_instance.read.return_value = _PLAINTEXT + self.mock_stream_decryptor_instance.header = _HEADER self.mock_stream_decryptor.return_value = self.mock_stream_decryptor_instance self.mock_stream_decryptor_instance.__enter__.return_value = self.mock_stream_decryptor_instance yield @@ -47,14 +52,14 @@ def apply_fixtures(self): def test_encrypt(self): test_ciphertext, test_header = aws_encryption_sdk.encrypt(a=sentinel.a, b=sentinel.b, c=sentinel.b) self.mock_stream_encryptor.called_once_with(a=sentinel.a, b=sentinel.b, c=sentinel.b) - assert test_ciphertext is sentinel.ciphertext - assert test_header is sentinel.header + assert test_ciphertext is _CIPHERTEXT + assert test_header is _HEADER def test_decrypt(self): test_plaintext, test_header = aws_encryption_sdk.decrypt(a=sentinel.a, b=sentinel.b, c=sentinel.b) self.mock_stream_encryptor.called_once_with(a=sentinel.a, b=sentinel.b, c=sentinel.b) - assert test_plaintext is sentinel.plaintext - assert test_header is sentinel.header + assert test_plaintext is _PLAINTEXT + assert test_header is _HEADER def test_stream_encryptor_e(self): test = aws_encryption_sdk.stream(mode="e", a=sentinel.a, b=sentinel.b, c=sentinel.b) diff --git a/test/unit/test_structures.py b/test/unit/test_structures.py index e1070c574..f047d5f11 100644 --- a/test/unit/test_structures.py +++ b/test/unit/test_structures.py @@ -13,8 +13,16 @@ """Unit test suite for aws_encryption_sdk.structures""" import pytest -from aws_encryption_sdk.identifiers import Algorithm, ContentType, ObjectType, SerializationVersion -from aws_encryption_sdk.structures import DataKey, EncryptedDataKey, MasterKeyInfo, MessageHeader, RawDataKey +from aws_encryption_sdk.identifiers import Algorithm, ContentType, KeyringTraceFlag, ObjectType, SerializationVersion +from aws_encryption_sdk.structures import ( + CryptoResult, + DataKey, + EncryptedDataKey, + KeyringTrace, + MasterKeyInfo, + MessageHeader, + RawDataKey, +) from .unit_test_utils import all_invalid_kwargs, all_valid_kwargs @@ -57,6 +65,35 @@ key_provider=MasterKeyInfo(provider_id="asjnoa", key_info=b"aosjfoaiwej"), encrypted_data_key=b"aisofiawjef" ) ], + KeyringTrace: [ + dict( + wrapping_key=MasterKeyInfo(provider_id="foo", key_info=b"bar"), + flags={KeyringTraceFlag.WRAPPING_KEY_ENCRYPTED_DATA_KEY}, + ) + ], + CryptoResult: [ + dict( + result=b"super secret stuff", + header=MessageHeader( + version=SerializationVersion.V1, + type=ObjectType.CUSTOMER_AE_DATA, + algorithm=Algorithm.AES_256_GCM_IV12_TAG16_HKDF_SHA384_ECDSA_P384, + message_id=b"aosiejfoaiwej", + encryption_context={}, + encrypted_data_keys=set([]), + content_type=ContentType.FRAMED_DATA, + content_aad_length=32456, + header_iv_length=32456, + frame_length=234567, + ), + keyring_trace=( + KeyringTrace( + wrapping_key=MasterKeyInfo(provider_id="foo", key_info=b"bar"), + flags={KeyringTraceFlag.WRAPPING_KEY_ENCRYPTED_DATA_KEY}, + ), + ), + ) + ], } @@ -134,3 +171,33 @@ def test_raw_and_encrypted_data_key_from_data_key_fail(data_key_class): data_key_class.from_data_key(b"ahjseofij") excinfo.match(r"data_key must be type DataKey not *") + + +@pytest.fixture +def ex_result(): + return CryptoResult(**VALID_KWARGS[CryptoResult][0]) + + +def test_cryptoresult_len(ex_result): + assert len(ex_result) == 2 + + +def test_cryptoresult_unpack(ex_result): + data, header = ex_result + + assert data is ex_result.result + assert header is ex_result.header + + +def test_cryptoresult_getitem(ex_result): + data = ex_result[0] + header = ex_result[1] + + assert data is ex_result.result + assert header is ex_result.header + + +def test_cryptoresult_to_tuple(ex_result): + test = tuple(ex_result) + + assert test == ex_result._legacy_container