From 38cde0c21be8cab111cc3fef4262578aeec23424 Mon Sep 17 00:00:00 2001 From: Ben Farley <47006790+farleyb-amazon@users.noreply.github.com> Date: Thu, 27 May 2021 15:07:22 -0600 Subject: [PATCH] feat: Improvements to the message decryption process (2.x) (#213) See https://github.com/aws/aws-encryption-sdk-cli/security/advisories/GHSA-89v2-g37m-g3ff --- CHANGELOG.rst | 9 + README.rst | 5 +- requirements.txt | 2 +- src/aws_encryption_sdk_cli/__init__.py | 2 + .../internal/arg_parsing.py | 19 ++ .../internal/identifiers.py | 8 +- .../internal/io_handling.py | 39 ++- test/integration/integration_test_utils.py | 13 +- .../test_i_aws_encryption_sdk_cli.py | 309 ++++++++++++++++++ test/unit/internal/test_arg_parsing.py | 113 ++++++- test/unit/internal/test_io_handling.py | 111 ++++++- test/unit/test_aws_encryption_sdk_cli.py | 52 ++- tox.ini | 6 +- 13 files changed, 661 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 014542d..a6f9bc8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,15 @@ Changelog ********* +2.2.0 -- 2021-05-27 +=================== + +Features +-------- +* Improvements to the message decryption process + + See https://github.com/aws/aws-encryption-sdk-cli/security/advisories/GHSA-89v2-g37m-g3ff. + 2.1.0 -- 2020-10-27 =================== diff --git a/README.rst b/README.rst index 8ba4bad..7095297 100644 --- a/README.rst +++ b/README.rst @@ -43,7 +43,7 @@ Required Prerequisites ====================== * Python 2.7+ or 3.4+ -* aws-encryption-sdk >= 2.0.0 +* aws-encryption-sdk >= 2.2.0 Installation ============ @@ -168,7 +168,7 @@ Metadata Contents ````````````````` The metadata JSON contains the following fields: -* ``"mode"`` : ``"encrypt"``/``"decrypt"`` +* ``"mode"`` : ``"encrypt"``/``"decrypt"``/``"decrypt-unsigned"`` * ``"input"`` : Full path to input file (or ``""`` if stdin) * ``"output"`` : Full path to output file (or ``""`` if stdout) * ``"header"`` : JSON representation of `message header data`_ @@ -342,7 +342,6 @@ Allowed parameters: * **max_messages_encrypted** : Determines how long each entry can remain in the cache, beginning when it was added. * **max_bytes_encrypted** : Specifies the maximum number of bytes that a cached data key can encrypt. - Logging and Verbosity --------------------- The ``-v`` argument allows you to tune the verbosity of the built-in logging to your desired level. diff --git a/requirements.txt b/requirements.txt index 2381837..94058e6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ base64io>=1.0.1 -aws-encryption-sdk~=2.0 +aws-encryption-sdk~=2.2 setuptools attrs>=17.1.0 diff --git a/src/aws_encryption_sdk_cli/__init__.py b/src/aws_encryption_sdk_cli/__init__.py index 6117f71..23dfd4b 100644 --- a/src/aws_encryption_sdk_cli/__init__.py +++ b/src/aws_encryption_sdk_cli/__init__.py @@ -185,6 +185,8 @@ def process_cli_request(stream_args, parsed_args): # noqa: C901 required_encryption_context=parsed_args.encryption_context, required_encryption_context_keys=parsed_args.required_encryption_context_keys, commitment_policy=commitment_policy, + buffer_output=parsed_args.buffer, + max_encrypted_data_keys=parsed_args.max_encrypted_data_keys, ) if parsed_args.input == "-": diff --git a/src/aws_encryption_sdk_cli/internal/arg_parsing.py b/src/aws_encryption_sdk_cli/internal/arg_parsing.py index bf2f0d4..636e74e 100644 --- a/src/aws_encryption_sdk_cli/internal/arg_parsing.py +++ b/src/aws_encryption_sdk_cli/internal/arg_parsing.py @@ -192,6 +192,14 @@ def _build_parser(): "-d", "--decrypt", dest="action", action="store_const", const="decrypt", help="Decrypt data" ) parser.add_dummy_redirect_argument("--decrypt") + operating_action.add_argument( + "--decrypt-unsigned", + dest="action", + action="store_const", + const="decrypt-unsigned", + help="Decrypt data and enforce messages are unsigned during decryption.", + ) + parser.add_dummy_redirect_argument("--decrypt-unsigned") # For each argument added to this group, a dummy redirect argument must # be added to the parent parser for each long form option string. @@ -258,6 +266,10 @@ def _build_parser(): ), ) + parser.add_argument( + "-b", "--buffer", action="store_true", help="Buffer result in memory before releasing to output" + ) + parser.add_argument( "-i", "--input", @@ -315,6 +327,13 @@ def _build_parser(): ), ) + parser.add_argument( + "--max-encrypted-data-keys", + type=int, + action=UniqueStoreAction, + help="Maximum number of encrypted data keys to wrap (during encryption) or to unwrap (during decryption)", + ) + parser.add_argument( "--suffix", nargs="?", diff --git a/src/aws_encryption_sdk_cli/internal/identifiers.py b/src/aws_encryption_sdk_cli/internal/identifiers.py index a445f41..8e9d71c 100644 --- a/src/aws_encryption_sdk_cli/internal/identifiers.py +++ b/src/aws_encryption_sdk_cli/internal/identifiers.py @@ -31,10 +31,14 @@ "DEFAULT_MASTER_KEY_PROVIDER", "OperationResult", ) -__version__ = "2.1.0" # type: str +__version__ = "2.2.0" # type: str #: Suffix added to output files if specific output filename is not specified. -OUTPUT_SUFFIX = {"encrypt": ".encrypted", "decrypt": ".decrypted"} # type: Dict[str, str] +OUTPUT_SUFFIX = { + "encrypt": ".encrypted", + "decrypt": ".decrypted", + "decrypt-unsigned": ".decrypted", +} # type: Dict[str, str] ALGORITHM_NAMES = { alg for alg in dir(aws_encryption_sdk.Algorithm) if not alg.startswith("_") diff --git a/src/aws_encryption_sdk_cli/internal/io_handling.py b/src/aws_encryption_sdk_cli/internal/io_handling.py index 8962f4a..b93b9c9 100644 --- a/src/aws_encryption_sdk_cli/internal/io_handling.py +++ b/src/aws_encryption_sdk_cli/internal/io_handling.py @@ -24,6 +24,7 @@ from aws_encryption_sdk.materials_managers import CommitmentPolicy # noqa pylint: disable=unused-import from base64io import Base64IO +from aws_encryption_sdk_cli.exceptions import BadUserArgumentError from aws_encryption_sdk_cli.internal.identifiers import OUTPUT_SUFFIX, OperationResult from aws_encryption_sdk_cli.internal.logging_utils import LOGGER_NAME from aws_encryption_sdk_cli.internal.metadata import MetadataWriter, json_ready_header, json_ready_header_auth @@ -142,6 +143,21 @@ def _output_dir(source_root, destination_root, source_dir): return os.path.join(destination_root, suffix) +def _is_decrypt_mode(mode): + # type: (str) -> bool + """ + Determines whether the provided mode does decryption + + :param str filepath: Full file path to file in question + :rtype: bool + """ + if mode in ("decrypt", "decrypt-unsigned"): + return True + if mode == "encrypt": + return False + raise BadUserArgumentError("Mode {mode} has not been implemented".format(mode=mode)) + + @attr.s(hash=False, init=False) class IOHandler(object): """Common handler for all IO operations. Holds common configuration values used for all @@ -153,6 +169,7 @@ class IOHandler(object): :param bool no_overwrite: Should never overwrite existing files :param bool decode_input: Should input be base64 decoded before operation :param bool encode_output: Should output be base64 encoded after operation + :param bool buffer_output: Should buffer entire output before releasing to destination :param dict required_encryption_context: Encryption context key-value pairs to require :param list required_encryption_context_keys: Encryption context keys to require """ @@ -162,12 +179,13 @@ class IOHandler(object): no_overwrite = attr.ib(validator=attr.validators.instance_of(bool)) decode_input = attr.ib(validator=attr.validators.instance_of(bool)) encode_output = attr.ib(validator=attr.validators.instance_of(bool)) + buffer_output = attr.ib(validator=attr.validators.instance_of(bool)) required_encryption_context = attr.ib(validator=attr.validators.instance_of(dict)) required_encryption_context_keys = attr.ib( validator=attr.validators.instance_of(list) ) # noqa pylint: disable=invalid-name - def __init__( + def __init__( # noqa pylint: disable=too-many-arguments self, metadata_writer, # type: MetadataWriter interactive, # type: bool @@ -177,6 +195,8 @@ def __init__( required_encryption_context, # type: Dict[str, str] required_encryption_context_keys, # type: List[str] commitment_policy, # type: CommitmentPolicy + buffer_output, + max_encrypted_data_keys, # type: Union[None, int] ): # type: (...) -> None """Workaround pending resolution of attrs/mypy interaction. @@ -190,7 +210,11 @@ def __init__( self.encode_output = encode_output self.required_encryption_context = required_encryption_context self.required_encryption_context_keys = required_encryption_context_keys # pylint: disable=invalid-name - self.client = aws_encryption_sdk.EncryptionSDKClient(commitment_policy=commitment_policy) + self.buffer_output = buffer_output + self.client = aws_encryption_sdk.EncryptionSDKClient( + commitment_policy=commitment_policy, + max_encrypted_data_keys=max_encrypted_data_keys, + ) attr.validate(self) def _single_io_write(self, stream_args, source, destination_writer): @@ -223,7 +247,7 @@ def _single_io_write(self, stream_args, source, destination_writer): else: metadata_kwargs["header_auth"] = json_ready_header_auth(header_auth) - if stream_args["mode"] == "decrypt": + if _is_decrypt_mode(str(stream_args["mode"])): discovered_ec = handler.header.encryption_context missing_keys = set(self.required_encryption_context_keys).difference(set(discovered_ec.keys())) missing_pairs = set(self.required_encryption_context.items()).difference(set(discovered_ec.items())) @@ -243,9 +267,12 @@ def _single_io_write(self, stream_args, source, destination_writer): return OperationResult.FAILED_VALIDATION metadata.write_metadata(**metadata_kwargs) - for chunk in handler: - _destination.write(chunk) - _destination.flush() + if self.buffer_output: + _destination.write(handler.read()) + else: + for chunk in handler: + _destination.write(chunk) + _destination.flush() return OperationResult.SUCCESS def process_single_operation(self, stream_args, source, destination): diff --git a/test/integration/integration_test_utils.py b/test/integration/integration_test_utils.py index 1b473be..2697aec 100644 --- a/test/integration/integration_test_utils.py +++ b/test/integration/integration_test_utils.py @@ -72,7 +72,7 @@ def encrypt_args_template(metadata=False, caching=False, encode=False, decode=Fa return template -def decrypt_args_template(metadata=False, encode=False, decode=False, discovery=True): +def decrypt_args_template(metadata=False, encode=False, decode=False, discovery=True, buffer=False): template = "-d -i {source} -o {target} " if metadata: template += " {metadata}" @@ -84,6 +84,17 @@ def decrypt_args_template(metadata=False, encode=False, decode=False, discovery= template += " --decode" if discovery: template += " --wrapping-keys discovery=true" + if buffer: + template += " --buffer" + return template + + +def decrypt_unsigned_args_template(metadata=False): + template = "--decrypt-unsigned -i {source} -o {target} --wrapping-keys discovery=true" + if metadata: + template += " {metadata}" + else: + template += " -S" return template diff --git a/test/integration/test_i_aws_encryption_sdk_cli.py b/test/integration/test_i_aws_encryption_sdk_cli.py index 31857b1..287a264 100644 --- a/test/integration/test_i_aws_encryption_sdk_cli.py +++ b/test/integration/test_i_aws_encryption_sdk_cli.py @@ -11,6 +11,7 @@ # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. """Integration testing suite for AWS Encryption SDK CLI.""" +# pylint: disable=too-many-lines import base64 import filecmp import json @@ -28,6 +29,7 @@ aws_encryption_cli_is_findable, cmk_arn_value, decrypt_args_template, + decrypt_unsigned_args_template, encrypt_args_template, is_windows, ) @@ -370,6 +372,158 @@ def test_cycle_discovery_false_wrong_key_id(tmpdir): assert "Unable to decrypt any data key" in message +def test_cycle_decrypt_unsigned_success(tmpdir): + plaintext = tmpdir.join("source_plaintext") + plaintext.write_binary(os.urandom(1024)) + ciphertext = tmpdir.join("ciphertext") + decrypted = tmpdir.join("decrypted") + metadata = tmpdir.join("metadata") + + encrypt_args = encrypt_args_template(metadata=True).format( + source=str(plaintext), target=str(ciphertext), metadata="--metadata-output " + str(metadata) + ) + # Use an unsigned algorithm for encryption + encrypt_args += " --algorithm AES_256_GCM_HKDF_SHA512_COMMIT_KEY" + decrypt_args = decrypt_unsigned_args_template(metadata=True).format( + source=str(ciphertext), target=str(decrypted), metadata="--metadata-output " + str(metadata) + ) + + aws_encryption_sdk_cli.cli(shlex.split(encrypt_args, posix=not is_windows())) + aws_encryption_sdk_cli.cli(shlex.split(decrypt_args, posix=not is_windows())) + + output_metadata = [json.loads(line) for line in metadata.readlines()] + for line in output_metadata: + for key, value in (("a", "b"), ("c", "d")): + assert line["header"]["encryption_context"][key] == value + + assert output_metadata[0]["mode"] == "encrypt" + assert output_metadata[0]["input"] == str(plaintext) + assert output_metadata[0]["output"] == str(ciphertext) + assert "header_auth" not in output_metadata[0] + assert output_metadata[1]["mode"] == "decrypt-unsigned" + assert output_metadata[1]["input"] == str(ciphertext) + assert output_metadata[1]["output"] == str(decrypted) + assert "header_auth" in output_metadata[1] + + +def test_cycle_decrypt_unsigned_fails_on_signed_message(tmpdir): + plaintext = tmpdir.join("source_plaintext") + plaintext.write_binary(os.urandom(1024)) + ciphertext = tmpdir.join("ciphertext") + decrypted = tmpdir.join("decrypted") + metadata = tmpdir.join("metadata") + + encrypt_args = encrypt_args_template(metadata=True).format( + source=str(plaintext), target=str(ciphertext), metadata="--metadata-output " + str(metadata) + ) + # Use a signed algorithm for encryption + encrypt_args += " --algorithm AES_256_GCM_HKDF_SHA512_COMMIT_KEY_ECDSA_P384" + decrypt_args = decrypt_unsigned_args_template(metadata=True).format( + source=str(ciphertext), target=str(decrypted), metadata="--metadata-output " + str(metadata) + ) + + aws_encryption_sdk_cli.cli(shlex.split(encrypt_args, posix=not is_windows())) + message = aws_encryption_sdk_cli.cli(shlex.split(decrypt_args, posix=not is_windows())) + + output_metadata = [json.loads(line) for line in metadata.readlines()] + for line in output_metadata: + for key, value in (("a", "b"), ("c", "d")): + assert line["header"]["encryption_context"][key] == value + + assert output_metadata[0]["mode"] == "encrypt" + assert output_metadata[0]["input"] == str(plaintext) + assert output_metadata[0]["output"] == str(ciphertext) + assert "header_auth" not in output_metadata[0] + assert not decrypted.isfile() + assert "Cannot decrypt signed message in decrypt-unsigned mode" in message + + +@pytest.mark.parametrize( + "max_encrypted_data_keys, is_valid", + ( + (1, True), + (10, True), + (2 ** 16 - 1, True), + (2 ** 16, True), + (0, False), + (-1, False), + ), +) +def test_max_encrypted_data_key_valid_values(tmpdir, max_encrypted_data_keys, is_valid): + plaintext = tmpdir.join("source_plaintext") + plaintext.write_binary(os.urandom(1024)) + ciphertext = tmpdir.join("ciphertext") + + encrypt_args = encrypt_args_template().format(source=str(plaintext), target=str(ciphertext)) + encrypt_args += " --max-encrypted-data-keys {}".format(max_encrypted_data_keys) + message = aws_encryption_sdk_cli.cli(shlex.split(encrypt_args, posix=not is_windows())) + if is_valid: + assert message is None + else: + assert "max_encrypted_data_keys cannot be less than 1" in message + + +@pytest.mark.parametrize("num_keys", (2, 3)) +def test_cycle_within_max_encrypted_data_keys(tmpdir, num_keys): + plaintext = tmpdir.join("source_plaintext") + plaintext.write_binary(os.urandom(1024)) + ciphertext = tmpdir.join("ciphertext") + decrypted = tmpdir.join("decrypted") + + extra_key_arg = " -w key={}".format(cmk_arn_value()) + max_edks_arg = " --max-encrypted-data-keys {}".format(3) + + encrypt_args = encrypt_args_template().format(source=str(plaintext), target=str(ciphertext)) + encrypt_args += max_edks_arg + encrypt_args += extra_key_arg * (num_keys - 1) + message = aws_encryption_sdk_cli.cli(shlex.split(encrypt_args, posix=not is_windows())) + assert message is None + + decrypt_args = decrypt_args_template().format(source=str(ciphertext), target=str(decrypted)) + decrypt_args += max_edks_arg + message = aws_encryption_sdk_cli.cli(shlex.split(decrypt_args, posix=not is_windows())) + assert message is None + + +def test_encrypt_over_max_encrypted_data_keys(tmpdir): + plaintext = tmpdir.join("source_plaintext") + plaintext.write_binary(os.urandom(1024)) + ciphertext = tmpdir.join("ciphertext") + + extra_key_arg = " -w key={}".format(cmk_arn_value()) + max_edks_arg = " --max-encrypted-data-keys {}".format(3) + + encrypt_args = encrypt_args_template().format(source=str(plaintext), target=str(ciphertext)) + encrypt_args += max_edks_arg + encrypt_args += extra_key_arg * 3 + message = aws_encryption_sdk_cli.cli(shlex.split(encrypt_args, posix=not is_windows())) + assert message is not None + assert "MaxEncryptedDataKeysExceeded" in message + assert "Number of encrypted data keys found larger than configured value" in message + + +def test_decrypt_over_max_encrypted_data_keys(tmpdir): + plaintext = tmpdir.join("source_plaintext") + plaintext.write_binary(os.urandom(1024)) + ciphertext = tmpdir.join("ciphertext") + decrypted = tmpdir.join("decrypted") + + extra_key_arg = " -w key={}".format(cmk_arn_value()) + max_edks_arg = " --max-encrypted-data-keys {}".format(3) + + encrypt_args = encrypt_args_template().format(source=str(plaintext), target=str(ciphertext)) + encrypt_args += extra_key_arg * 3 + message = aws_encryption_sdk_cli.cli(shlex.split(encrypt_args, posix=not is_windows())) + assert message is None + + decrypt_args = decrypt_args_template().format(source=str(ciphertext), target=str(decrypted)) + decrypt_args += max_edks_arg + message = aws_encryption_sdk_cli.cli(shlex.split(decrypt_args, posix=not is_windows())) + assert message is not None + assert "MaxEncryptedDataKeysExceeded" in message + assert "Number of encrypted data keys found larger than configured value" in message + + @pytest.mark.parametrize("required_encryption_context", ("a", "c", "a c", "a=b", "a=b c", "c=d", "a c=d", "a=b c=d")) def test_file_to_file_decrypt_required_encryption_context_success(tmpdir, required_encryption_context): plaintext = tmpdir.join("source_plaintext") @@ -391,6 +545,29 @@ def test_file_to_file_decrypt_required_encryption_context_success(tmpdir, requir assert filecmp.cmp(str(plaintext), str(decrypted)) +@pytest.mark.parametrize("required_encryption_context", ("a", "c", "a c", "a=b", "a=b c", "c=d", "a c=d", "a=b c=d")) +def test_file_to_file_decrypt_unsigned_required_encryption_context_success(tmpdir, required_encryption_context): + plaintext = tmpdir.join("source_plaintext") + ciphertext = tmpdir.join("ciphertext") + decrypted = tmpdir.join("decrypted") + with open(str(plaintext), "wb") as f: + f.write(os.urandom(1024)) + + encrypt_args = encrypt_args_template().format(source=str(plaintext), target=str(ciphertext)) + # Use an unsigned algorithm for encryption + encrypt_args += " --algorithm AES_256_GCM_HKDF_SHA512_COMMIT_KEY" + decrypt_args = ( + decrypt_unsigned_args_template().format(source=str(ciphertext), target=str(decrypted)) + + " --encryption-context " + + required_encryption_context + ) + + aws_encryption_sdk_cli.cli(shlex.split(encrypt_args, posix=not is_windows())) + aws_encryption_sdk_cli.cli(shlex.split(decrypt_args, posix=not is_windows())) + + assert filecmp.cmp(str(plaintext), str(decrypted)) + + @pytest.mark.parametrize("required_encryption_context", ("a=VALUE_NOT_FOUND", "KEY_NOT_FOUND")) def test_file_to_file_decrypt_required_encryption_context_fail(tmpdir, required_encryption_context): plaintext = tmpdir.join("source_plaintext") @@ -418,6 +595,35 @@ def test_file_to_file_decrypt_required_encryption_context_fail(tmpdir, required_ assert parsed_metadata["reason"] == "Missing encryption context key or value" +@pytest.mark.parametrize("required_encryption_context", ("a=VALUE_NOT_FOUND", "KEY_NOT_FOUND")) +def test_file_to_file_decrypt_unsigned_required_encryption_context_fail(tmpdir, required_encryption_context): + plaintext = tmpdir.join("source_plaintext") + plaintext.write_binary(os.urandom(1024)) + ciphertext = tmpdir.join("ciphertext") + metadata_file = tmpdir.join("metadata") + decrypted = tmpdir.join("decrypted") + + encrypt_args = encrypt_args_template().format(source=str(plaintext), target=str(ciphertext)) + # Use an unsigned algorithm for encryption + encrypt_args += " --algorithm AES_256_GCM_HKDF_SHA512_COMMIT_KEY" + decrypt_args = ( + decrypt_unsigned_args_template(metadata=True).format( + source=str(ciphertext), target=str(decrypted), metadata=" --metadata-output " + str(metadata_file) + ) + + " --encryption-context " + + required_encryption_context + ) + + aws_encryption_sdk_cli.cli(shlex.split(encrypt_args, posix=not is_windows())) + aws_encryption_sdk_cli.cli(shlex.split(decrypt_args, posix=not is_windows())) + + assert not decrypted.isfile() + raw_metadata = metadata_file.read() + parsed_metadata = json.loads(raw_metadata) + assert parsed_metadata["skipped"] + assert parsed_metadata["reason"] == "Missing encryption context key or value" + + def test_file_to_file_cycle(tmpdir): plaintext = tmpdir.join("source_plaintext") ciphertext = tmpdir.join("ciphertext") @@ -434,6 +640,72 @@ def test_file_to_file_cycle(tmpdir): assert filecmp.cmp(str(plaintext), str(decrypted)) +# This test may result in a false positive if the input is not large enough +# Note that test_file_to_stdout_decrypt_buffer_output_with_failure helps confirm this is not a false positive +@pytest.mark.skipif(not aws_encryption_cli_is_findable(), reason="aws-encryption-cli executable could not be found.") +def test_file_to_stdout_decrypt_buffer_output_with_failure(tmpdir): + plaintext = tmpdir.join("source_plaintext") + # Use an input large enough that results in processing in several chunks + plaintext.write_binary(os.urandom(16384)) + ciphertext = tmpdir.join("ciphertext") + + encrypt_args = encrypt_args_template().format(source=str(plaintext), target=str(ciphertext)) + decrypt_args = "aws-encryption-cli " + decrypt_args_template(buffer=True).format(source=str(ciphertext), target="-") + + aws_encryption_sdk_cli.cli(shlex.split(encrypt_args, posix=not is_windows())) + + # Tamper with encryption result to get an error on decrypt + with open(str(ciphertext), "rb+") as f: + f.seek(-1, os.SEEK_END) + f.truncate() + + # pylint: disable=consider-using-with + proc = Popen(shlex.split(decrypt_args, posix=not is_windows()), stdout=PIPE, stdin=PIPE, stderr=PIPE) + decrypted_output, stderr = proc.communicate() + + # Verify that no output was written + assert decrypted_output == b"" + assert b"Encountered unexpected error" in stderr + # Verify the no exception was raised trying to delete verifiable non-existant "-" file, + # to verify that we did not attempt to do that + assert b"OSError" not in stderr # Python 2 + assert b"FileNotFoundError" not in stderr # Python 3 + + +# This test may result in a false negative if the input is not large enough +# Note that this test helps confirm that test_file_to_stdout_decrypt_buffer_output_with_failure is not a false positive +@pytest.mark.skipif(not aws_encryption_cli_is_findable(), reason="aws-encryption-cli executable could not be found.") +def test_file_to_stdout_decrypt_no_buffering_with_failure(tmpdir): + plaintext = tmpdir.join("source_plaintext") + # Use an input large enough that results in processing in several chunks + plaintext.write_binary(os.urandom(16384)) + ciphertext = tmpdir.join("ciphertext") + + encrypt_args = encrypt_args_template().format(source=str(plaintext), target=str(ciphertext)) + decrypt_args = "aws-encryption-cli " + decrypt_args_template(buffer=False).format( + source=str(ciphertext), target="-" + ) + + aws_encryption_sdk_cli.cli(shlex.split(encrypt_args, posix=not is_windows())) + + # Tamper with encryption result to get an error on decrypt + with open(str(ciphertext), "rb+") as f: + f.seek(-1, os.SEEK_END) + f.truncate() + + # pylint: disable=consider-using-with + proc = Popen(shlex.split(decrypt_args, posix=not is_windows()), stdout=PIPE, stdin=PIPE, stderr=PIPE) + decrypted_output, stderr = proc.communicate() + + # Verify that output was not buffered and some output was written to stout + assert len(decrypted_output) > 0 + assert b"Encountered unexpected error" in stderr + # Verify the no exception was raised trying to delete verifiable non-existant "-" file, + # to verify that we did not attempt to do that + assert b"OSError" not in stderr # Python 2 + assert b"FileNotFoundError" not in stderr # Python 3 + + @pytest.mark.skipif(is_windows(), reason=WINDOWS_SKIP_MESSAGE) def test_file_to_file_cycle_target_through_symlink(tmpdir): plaintext = tmpdir.join("source_plaintext") @@ -652,6 +924,43 @@ def test_file_to_stdout_decrypt_required_encryption_context_fail(tmpdir, require assert parsed_metadata["reason"] == "Missing encryption context key or value" +@pytest.mark.skipif(not aws_encryption_cli_is_findable(), reason="aws-encryption-cli executable could not be found.") +@pytest.mark.parametrize("required_encryption_context", ("a=VALUE_NOT_FOUND", "KEY_NOT_FOUND")) +def test_file_to_stdout_decrypt_unsigned_required_encryption_context_fail(tmpdir, required_encryption_context): + plaintext = tmpdir.join("source_plaintext") + plaintext.write_binary(os.urandom(1024)) + ciphertext = tmpdir.join("ciphertext") + metadata_file = tmpdir.join("metadata") + + encrypt_args = encrypt_args_template().format(source=str(plaintext), target=str(ciphertext)) + # Use an unsigned algorithm for encryption + encrypt_args += " --algorithm AES_256_GCM_HKDF_SHA512_COMMIT_KEY" + decrypt_args = ( + "aws-encryption-cli " + + decrypt_unsigned_args_template(metadata=True).format( + source=str(ciphertext), target="-", metadata=" --metadata-output " + str(metadata_file) + ) + + " --encryption-context " + + required_encryption_context + ) + + aws_encryption_sdk_cli.cli(shlex.split(encrypt_args, posix=not is_windows())) + # pylint: disable=consider-using-with + proc = Popen(shlex.split(decrypt_args, posix=not is_windows()), stdout=PIPE, stdin=PIPE, stderr=PIPE) + decrypted_output, stderr = proc.communicate() + # Verify that no output was written + assert decrypted_output == b"" + # Verify the no exception was raised trying to delete verifiable non-existant "-" file, + # to verify that we did not attempt to do that + assert b"OSError" not in stderr # Python 2 + assert b"FileNotFoundError" not in stderr # Python 3 + raw_metadata = metadata_file.read() + parsed_metadata = json.loads(raw_metadata) + assert parsed_metadata["output"] == "" + assert parsed_metadata["skipped"] + assert parsed_metadata["reason"] == "Missing encryption context key or value" + + def test_dir_to_dir_cycle(tmpdir): plaintext_dir = tmpdir.mkdir("plaintext") ciphertext_dir = tmpdir.mkdir("ciphertext") diff --git a/test/unit/internal/test_arg_parsing.py b/test/unit/internal/test_arg_parsing.py index db1aaee..6c1d983 100644 --- a/test/unit/internal/test_arg_parsing.py +++ b/test/unit/internal/test_arg_parsing.py @@ -198,7 +198,7 @@ def test_unique_store_action_second_call(): mock_parser.error.assert_called_once_with("SPECIAL_ATTRIBUTE argument may not be specified more than once") -def build_expected_good_args(from_file=False): # pylint: disable=too-many-locals +def build_expected_good_args(from_file=False): # pylint: disable=too-many-locals,too-many-statements encrypt = "-e" decrypt = "-d" suppress_metadata = " -S" @@ -219,6 +219,7 @@ def build_expected_good_args(from_file=False): # pylint: disable=too-many-local good_args.append((encrypt_flag + suppress_metadata + valid_io + mkp_1, "action", "encrypt")) for decrypt_flag in (decrypt, "--decrypt"): good_args.append((decrypt_flag + suppress_metadata + valid_io + mkp_1, "action", "decrypt")) + good_args.append(("--decrypt-unsigned" + suppress_metadata + valid_io + mkp_1, "action", "decrypt-unsigned")) # wrapping key config good_args.append((default_encrypt, "wrapping_keys", [mkp_1_parsed])) @@ -265,6 +266,10 @@ def build_expected_good_args(from_file=False): # pylint: disable=too-many-local good_args.append((default_encrypt, "max_length", None)) good_args.append((default_encrypt + " --max-length 99", "max_length", 99)) + # max encrypted data keys + good_args.append((default_encrypt, "max_encrypted_data_keys", None)) + good_args.append((default_encrypt + " --max-encrypted-data-keys 99", "max_encrypted_data_keys", 99)) + # interactive good_args.append((default_encrypt, "interactive", False)) good_args.append((default_encrypt + " --interactive", "interactive", True)) @@ -297,6 +302,11 @@ def build_expected_good_args(from_file=False): # pylint: disable=too-many-local ) ) + # buffer + good_args.append((default_encrypt, "buffer", False)) + for recursive_flag in (" -b", " --buffer"): + good_args.append((default_encrypt + recursive_flag, "buffer", True)) + return good_args @@ -345,6 +355,7 @@ def build_bad_dummy_arguments(): partial_patterns = { "-decrypt": "{arg} -i - -o - -S", "-encrypt": "{arg} -i - -o - -S", + "-decrypt-unsigned": "{arg} -i - -o - -S", "-input": "-d {arg} - -o - -S", "-output": "-d -i - {arg} - -S", } @@ -525,12 +536,65 @@ def test_process_caching_config_required_parameters_missing(source): } ], ), - ([["provider=aws-kms", "discovery=true"]], "decrypt", [{"provider": "aws-kms", "key": [], "discovery": True}]), - ( + ( # decrypt-unsigned, with keys, no discovery + [["provider=ex_provider", "key=ex_key_1"]], + "decrypt-unsigned", + [{"provider": "ex_provider", "key": ["ex_key_1"]}], + ), + ( # decrypt-unsigned, explicit discovery true, no filter + [["provider=aws-kms", "discovery=true"]], + "decrypt-unsigned", + [{"provider": "aws-kms", "key": [], "discovery": True}], + ), + ( # decrypt-unsigned, explicit discovery false, no filter + [["provider=aws-kms", "discovery=false", "key=ex_key_1"]], + "decrypt-unsigned", + [{"provider": "aws-kms", "key": ["ex_key_1"], "discovery": False}], + ), + ( # decrypt-unsigned, explicit discovery, filter + [["provider=aws-kms", "discovery=true", "discovery-account=123", "discovery-partition=aws"]], + "decrypt-unsigned", + [ + { + "provider": "aws-kms", + "key": [], + "discovery": True, + "discovery-account": ["123"], + "discovery-partition": "aws", + } + ], + ), + ( # decrypt-unsigned, explicit discovery, filter multiple accounts + [ + [ + "provider=aws-kms", + "discovery=true", + "discovery-account=123", + "discovery-account=456", + "discovery-partition=aws", + ] + ], + "decrypt-unsigned", + [ + { + "provider": "aws-kms", + "key": [], + "discovery": True, + "discovery-account": ["123", "456"], + "discovery-partition": "aws", + } + ], + ), + ( # decrypt, non-kms provider, explicit discovery true [["provider=" + identifiers.DEFAULT_MASTER_KEY_PROVIDER, "discovery=true"]], "decrypt", [{"provider": identifiers.DEFAULT_MASTER_KEY_PROVIDER, "key": [], "discovery": True}], ), + ( # decrypt-unsigned, non-kms provider, explicit discovery true + [["provider=" + identifiers.DEFAULT_MASTER_KEY_PROVIDER, "discovery=true"]], + "decrypt-unsigned", + [{"provider": identifiers.DEFAULT_MASTER_KEY_PROVIDER, "key": [], "discovery": True}], + ), ( [["key=ex_key_1"]], "encrypt", @@ -560,17 +624,19 @@ def test_process_wrapping_key_provider_configs_encrypt_with_discovery(): excinfo.match(r"Discovery attributes are supported only on decryption for AWS KMS keys") -def test_process_wrapping_key_provider_configs_decrypt_without_discovery(): +@pytest.mark.parametrize("decrypt_mode", ("decrypt", "decrypt-unsigned")) +def test_process_wrapping_key_provider_configs_decrypt_without_discovery(decrypt_mode): with pytest.raises(ParameterParseError) as excinfo: - arg_parsing._process_wrapping_key_provider_configs([["provider=aws-kms"]], "decrypt") + arg_parsing._process_wrapping_key_provider_configs([["provider=aws-kms"]], decrypt_mode) excinfo.match(re.escape("When discovery is false (disabled), you must specify at least one wrapping key")) -def test_process_wrapping_key_provider_configs_multiple_discovery_partition(): +@pytest.mark.parametrize("decrypt_mode", ("decrypt", "decrypt-unsigned")) +def test_process_wrapping_key_provider_configs_multiple_discovery_partition(decrypt_mode): args = [["discovery=true", "discovery-account=123", "discovery-partition=aws", "discovery-partition=aws-gov"]] with pytest.raises(ParameterParseError) as excinfo: - arg_parsing._process_wrapping_key_provider_configs(args, "decrypt") + arg_parsing._process_wrapping_key_provider_configs(args, decrypt_mode) excinfo.match(r"You can only specify discovery-partition once") @@ -584,10 +650,11 @@ def test_process_wrapping_key_provider_configs_not_exactly_one_provider(): excinfo.match(r'You must provide exactly one "provider" for each wrapping key provider configuration. 2 provided') +@pytest.mark.parametrize("decrypt_mode", ("decrypt", "decrypt-unsigned")) @pytest.mark.parametrize("args", ["discovery=true", "discovery-account=123", "discovery-partition=aws"]) -def test_process_wrapping_key_provider_configs_discovery_with_nonkms_provider(args): +def test_process_wrapping_key_provider_configs_discovery_with_nonkms_provider(args, decrypt_mode): with pytest.raises(ParameterParseError) as excinfo: - arg_parsing._process_wrapping_key_provider_configs([["provider=notkms", args]], "decrypt") + arg_parsing._process_wrapping_key_provider_configs([["provider=notkms", args]], decrypt_mode) excinfo.match(r"Discovery attributes are supported only for AWS KMS wrapping keys") @@ -620,6 +687,7 @@ def test_parse_args( version=False, dummy_redirect=None, commitment_policy=CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + buffer=sentinel.buffer_output, ) patch_build_parser.return_value.parse_args.return_value = mock_parsed_args test = arg_parsing.parse_args(sentinel.raw_args) @@ -700,8 +768,16 @@ def test_parse_args_error_raised_in_post_processing( ( ("encrypt", None, None, {}, []), ("decrypt", None, None, {}, []), + ("decrypt-unsigned", None, None, {}, []), ("encrypt", ["encryption=context", "with=values"], None, {"encryption": "context", "with": "values"}, []), ("decrypt", ["encryption=context", "with=values"], None, {"encryption": "context", "with": "values"}, []), + ( + "decrypt-unsigned", + ["encryption=context", "with=values"], + None, + {"encryption": "context", "with": "values"}, + [], + ), ( "encrypt", ["encryption=context", "with=values"], @@ -723,6 +799,20 @@ def test_parse_args_error_raised_in_post_processing( {"encryption": "context", "with": "values"}, ["key_1", "key_2", "key_3"], ), + ( + "decrypt-unsigned", + ["encryption=context", "with=values"], + ["key_1", "key_2"], + {"encryption": "context", "with": "values"}, + ["key_1", "key_2"], + ), + ( + "decrypt-unsigned", + ["encryption=context", "with=values", "key_3"], + ["key_1", "key_2"], + {"encryption": "context", "with": "values"}, + ["key_1", "key_2", "key_3"], + ), ), ) def test_process_encryption_context( @@ -753,6 +843,11 @@ def test_process_encryption_context_encrypt_required_key_fail(): "--decrypt --input - -S --output - -w provider=ex_p_1 key=exkey discovery=0 discovery-account=123", "--decrypt --input - -S --output - -w provider=ex_p_1 key=exkey discovery=0 discovery-partition=aws", "--decrypt --input - -S --output - -w provider=ex_pr_1 key=exkey discovery=0 discovery-partition=aws" + "--decrypt-unsigned --input - -S --output - -w provider=ex_p_1 key=exkey discovery=1 discovery-account=123", + "--decrypt-unsigned --input - -S --output - -w provider=ex_p_1 key=exkey discovery=1 discovery-partition=aws", + "--decrypt-unsigned --input - -S --output - -w provider=ex_p_1 key=exkey discovery=0 discovery-account=123", + "--decrypt-unsigned --input - -S --output - -w provider=ex_p_1 key=exkey discovery=0 discovery-partition=aws", + "--decrypt-unsigned --input - -S --output - -w provider=ex_pr_1 key=exkey discovery=0 discovery-partition=aws" " --discovery-account=123", ), ) diff --git a/test/unit/internal/test_io_handling.py b/test/unit/internal/test_io_handling.py index d5f87be..d0f8cf1 100644 --- a/test/unit/internal/test_io_handling.py +++ b/test/unit/internal/test_io_handling.py @@ -19,9 +19,10 @@ import pytest import six from aws_encryption_sdk.materials_managers import CommitmentPolicy -from mock import MagicMock, patch, sentinel +from mock import MagicMock, call, patch, sentinel from pytest_mock import mocker # noqa pylint: disable=unused-import +from aws_encryption_sdk_cli import BadUserArgumentError from aws_encryption_sdk_cli.internal import identifiers, io_handling, metadata from ..unit_test_utils import WINDOWS_SKIP_MESSAGE, is_windows @@ -104,6 +105,8 @@ def patch_json_ready_header_auth(mocker): required_encryption_context={}, required_encryption_context_keys=[], commitment_policy=CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + buffer_output=False, + max_encrypted_data_keys=None, ) @@ -173,6 +176,19 @@ def test_encoder(mocker, should_base64): assert test is sentinel.stream +@pytest.mark.parametrize("mode, expected", (("decrypt", True), ("decrypt-unsigned", True), ("encrypt", False))) +def test_is_decrypt_mode(mode, expected): + assert io_handling._is_decrypt_mode(mode) == expected + + +@pytest.mark.parametrize("invalid_mode", ("not-a-mode", "dedecrypt", "decrypt-signed", "encrypt-unsigned", "crypt")) +def test_is_decrypt_mode_exception(invalid_mode): + with pytest.raises(BadUserArgumentError) as excinfo: + io_handling._is_decrypt_mode(invalid_mode) + + excinfo.match(r"Mode {mode} has not been implemented".format(mode=invalid_mode)) + + def test_iohandler_attrs_good(): io_handling.IOHandler(**GOOD_IOHANDLER_KWARGS) @@ -188,6 +204,7 @@ def test_iohandler_attrs_good(): dict(encryption_context="not a dict"), dict(required_encryption_context_keys="not a list"), dict(commitment_policy="not a CommitmentPolicy"), + dict(buffer_output="not a bool"), ), ) def test_iohandler_attrs_fail(kwargs): @@ -252,6 +269,31 @@ def test_single_io_write_stream_decrypt( ) +def test_single_io_write_stream_decrypt_unsigned( + tmpdir, patch_aws_encryption_sdk_stream, patch_json_ready_header, patch_json_ready_header_auth, standard_handler +): + patch_aws_encryption_sdk_stream.return_value = io.BytesIO(DATA) + patch_aws_encryption_sdk_stream.return_value.header = MagicMock() + patch_aws_encryption_sdk_stream.return_value.header_auth = MagicMock() + target_file = tmpdir.join("target") + mock_source = MagicMock() + standard_handler.metadata_writer = MagicMock() + with open(str(target_file), "wb") as destination_writer: + standard_handler._single_io_write( + stream_args={"mode": "decrypt-unsigned", "a": sentinel.a, "b": sentinel.b}, + source=mock_source, + destination_writer=destination_writer, + ) + patch_json_ready_header_auth.assert_called_once_with(patch_aws_encryption_sdk_stream.return_value.header_auth) + standard_handler.metadata_writer.__enter__.return_value.write_metadata.assert_called_once_with( + mode="decrypt-unsigned", + input=mock_source.name, + output=destination_writer.name, + header=patch_json_ready_header.return_value, + header_auth=patch_json_ready_header_auth.return_value, + ) + + def test_single_io_write_stream_encode_output( tmpdir, patch_aws_encryption_sdk_stream, patch_json_ready_header, patch_json_ready_header_auth ): @@ -272,6 +314,48 @@ def test_single_io_write_stream_encode_output( assert target_file.read("rb") == base64.b64encode(DATA) +def test_single_io_write_stream_buffer_output( + tmpdir, patch_aws_encryption_sdk_stream, patch_json_ready_header, patch_json_ready_header_auth +): + mock_source = MagicMock() + mock_destination = MagicMock() + kwargs = GOOD_IOHANDLER_KWARGS.copy() + kwargs["buffer_output"] = True + handler = io_handling.IOHandler(**kwargs) + + handler._single_io_write( + stream_args={"mode": "encrypt", "a": sentinel.a, "b": sentinel.b}, + source=mock_source, + destination_writer=mock_destination, + ) + + patch_aws_encryption_sdk_stream.return_value.__enter__.return_value.read.assert_called_once() + mock_destination.__enter__.return_value.write.assert_called_once() + + +def test_single_io_write_stream_no_buffering( + tmpdir, patch_aws_encryption_sdk_stream, patch_json_ready_header, patch_json_ready_header_auth +): + patch_aws_encryption_sdk_stream.return_value.__enter__.return_value.__iter__ = MagicMock( + return_value=iter((sentinel.chunk_1, sentinel.chunk_2)) + ) + + mock_source = MagicMock() + mock_destination = MagicMock() + kwargs = GOOD_IOHANDLER_KWARGS.copy() + kwargs["buffer_output"] = False + handler = io_handling.IOHandler(**kwargs) + + handler._single_io_write( + stream_args={"mode": "encrypt", "a": sentinel.a, "b": sentinel.b}, + source=mock_source, + destination_writer=mock_destination, + ) + + patch_aws_encryption_sdk_stream.return_value.__enter__.return_value.__iter__.assert_called_once() + mock_destination.__enter__.return_value.write.assert_has_calls([call(sentinel.chunk_1), call(sentinel.chunk_2)]) + + def test_process_single_operation_stdout(patch_for_process_single_operation, patch_should_write_file, standard_handler): standard_handler.process_single_operation(stream_args=sentinel.stream_args, source=sentinel.source, destination="-") io_handling.IOHandler._single_io_write.assert_called_once_with( @@ -373,6 +457,10 @@ def test_should_write_file_does_exist(tmpdir, patch_input, interactive, no_overw ("decrypt", True, False, 0.75), ("decrypt", False, True, 1.0), ("decrypt", True, True, 1.0), + ("decrypt-unsigned", False, False, 1.0), + ("decrypt-unsigned", True, False, 0.75), + ("decrypt-unsigned", False, True, 1.0), + ("decrypt-unsigned", True, True, 1.0), ), ) def test_process_single_file( @@ -486,6 +574,20 @@ def test_process_single_file_failed_and_destination_does_not_exist( None, os.path.join("destination_dir", "source_filename.encrypted.decrypted"), ), + ( + os.path.join("source_dir", "source_filename"), + "destination_dir", + "decrypt-unsigned", + None, + os.path.join("destination_dir", "source_filename.decrypted"), + ), + ( + os.path.join("source_dir", "source_filename.encrypted"), + "destination_dir", + "decrypt-unsigned", + None, + os.path.join("destination_dir", "source_filename.encrypted.decrypted"), + ), ( os.path.join("source_dir", "source_filename"), "destination_dir", @@ -500,6 +602,13 @@ def test_process_single_file_failed_and_destination_does_not_exist( "CUSTOM_SUFFIX", os.path.join("destination_dir", "source_filenameCUSTOM_SUFFIX"), ), + ( + os.path.join("source_dir", "source_filename"), + "destination_dir", + "decrypt-unsigned", + "CUSTOM_SUFFIX", + os.path.join("destination_dir", "source_filenameCUSTOM_SUFFIX"), + ), ), ) def test_output_filename(source, destination, mode, suffix, output): diff --git a/test/unit/test_aws_encryption_sdk_cli.py b/test/unit/test_aws_encryption_sdk_cli.py index bdfccd0..1b704fb 100644 --- a/test/unit/test_aws_encryption_sdk_cli.py +++ b/test/unit/test_aws_encryption_sdk_cli.py @@ -264,6 +264,8 @@ def test_process_cli_request_source_dir_nonrecursive(tmpdir, patch_iohandler): encryption_context=sentinel.encryption_context, required_encryption_context_keys=sentinel.required_keys, commitment_policy=CommitmentPolicyArgs.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + buffer=sentinel.buffer_output, + max_encrypted_data_keys=None, ), ) @@ -276,6 +278,8 @@ def test_process_cli_request_source_dir_nonrecursive(tmpdir, patch_iohandler): required_encryption_context=sentinel.encryption_context, required_encryption_context_keys=sentinel.required_keys, commitment_policy=CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + buffer_output=sentinel.buffer_output, + max_encrypted_data_keys=None, ) assert not patch_iohandler.return_value.process_single_operation.called assert not patch_iohandler.return_value.process_dir.called @@ -300,6 +304,8 @@ def test_process_cli_request_no_commitment_policy(tmpdir, patch_iohandler): encryption_context=sentinel.encryption_context, required_encryption_context_keys=sentinel.required_keys, commitment_policy=None, + buffer=sentinel.buffer_output, + max_encrypted_data_keys=None, ), ) @@ -312,6 +318,8 @@ def test_process_cli_request_no_commitment_policy(tmpdir, patch_iohandler): required_encryption_context=sentinel.encryption_context, required_encryption_context_keys=sentinel.required_keys, commitment_policy=CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + buffer_output=sentinel.buffer_output, + max_encrypted_data_keys=None, ) assert not patch_iohandler.return_value.process_single_operation.called assert not patch_iohandler.return_value.process_dir.called @@ -335,6 +343,8 @@ def test_process_cli_request_source_dir_destination_nondir(tmpdir): encryption_context={}, required_encryption_context_keys=[], commitment_policy=CommitmentPolicyArgs.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + buffer=False, + max_encrypted_data_keys=None, ), ) excinfo.match(r"If operating on a source directory, destination must be an existing directory") @@ -356,6 +366,7 @@ def test_process_cli_request_source_dir_destination_dir(tmpdir, patch_iohandler) encode=sentinel.encode_output, metadata_output=MetadataWriter(True)(), commitment_policy=CommitmentPolicyArgs.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + buffer=sentinel.buffer_output, ), ) @@ -389,6 +400,7 @@ def test_process_cli_request_source_stdin(tmpdir, patch_iohandler): encode=sentinel.encode_output, metadata_output=MetadataWriter(True)(), commitment_policy=CommitmentPolicyArgs.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + buffer=sentinel.buffer_output, ) aws_encryption_sdk_cli.process_cli_request(stream_args=sentinel.stream_args, parsed_args=mock_parsed_args) assert not patch_iohandler.return_value.process_dir.called @@ -415,6 +427,7 @@ def test_process_cli_request_source_file_destination_dir(tmpdir, patch_iohandler encode=sentinel.encode_output, metadata_output=MetadataWriter(True)(), commitment_policy=CommitmentPolicyArgs.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + buffer=sentinel.buffer_output, ), ) assert not patch_iohandler.return_value.process_dir.called @@ -443,6 +456,7 @@ def test_process_cli_request_source_file_destination_file(tmpdir, patch_iohandle encode=sentinel.encode_output, metadata_output=MetadataWriter(True)(), commitment_policy=CommitmentPolicyArgs.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + buffer=sentinel.buffer_output, ), ) assert not patch_iohandler.return_value.process_dir.called @@ -469,6 +483,8 @@ def test_process_cli_request_invalid_source(tmpdir): encryption_context={}, required_encryption_context_keys=[], commitment_policy=CommitmentPolicyArgs.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + buffer=False, + max_encrypted_data_keys=None, ), ) excinfo.match(r"Invalid source. Must be a valid pathname pattern or stdin \(-\)") @@ -524,6 +540,7 @@ def test_process_cli_request_source_contains_directory_nonrecursive(tmpdir, patc decode=False, metadata_output=MetadataWriter(True)(), commitment_policy=CommitmentPolicyArgs.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + buffer=False, ), ) @@ -542,7 +559,12 @@ def test_process_cli_request_source_contains_directory_nonrecursive(tmpdir, patc ( ( MagicMock( - action=sentinel.mode, encryption_context=None, algorithm=None, frame_length=None, max_length=None + action=sentinel.mode, + encryption_context=None, + algorithm=None, + frame_length=None, + max_length=None, + max_encrypted_data_keys=None, ), {"materials_manager": sentinel.materials_manager, "mode": sentinel.mode}, ), @@ -553,6 +575,7 @@ def test_process_cli_request_source_contains_directory_nonrecursive(tmpdir, patc algorithm=None, frame_length=None, max_length=sentinel.max_length, + max_encrypted_data_keys=None, ), { "materials_manager": sentinel.materials_manager, @@ -562,7 +585,26 @@ def test_process_cli_request_source_contains_directory_nonrecursive(tmpdir, patc ), ( MagicMock( - action=sentinel.mode, encryption_context=None, algorithm=None, frame_length=None, max_length=None + action=sentinel.mode, + encryption_context=None, + algorithm=None, + frame_length=None, + max_length=None, + max_encrypted_data_keys=sentinel.max_encrypted_data_keys, + ), + { + "materials_manager": sentinel.materials_manager, + "mode": sentinel.mode, + }, + ), + ( + MagicMock( + action=sentinel.mode, + encryption_context=None, + algorithm=None, + frame_length=None, + max_length=None, + max_encrypted_data_keys=None, ), {"materials_manager": sentinel.materials_manager, "mode": sentinel.mode}, ), @@ -573,6 +615,7 @@ def test_process_cli_request_source_contains_directory_nonrecursive(tmpdir, patc algorithm="AES_256_GCM_IV12_TAG16_HKDF_SHA384_ECDSA_P384", frame_length=sentinel.frame_length, max_length=None, + max_encrypted_data_keys=None, ), {"materials_manager": sentinel.materials_manager, "mode": sentinel.mode}, ), @@ -583,6 +626,7 @@ def test_process_cli_request_source_contains_directory_nonrecursive(tmpdir, patc algorithm="AES_256_GCM_IV12_TAG16_HKDF_SHA384_ECDSA_P384", frame_length=sentinel.frame_length, max_length=None, + max_encrypted_data_keys=None, ), { "materials_manager": sentinel.materials_manager, @@ -599,6 +643,7 @@ def test_process_cli_request_source_contains_directory_nonrecursive(tmpdir, patc algorithm="AES_256_GCM_IV12_TAG16_HKDF_SHA384_ECDSA_P384", frame_length=sentinel.frame_length, max_length=None, + max_encrypted_data_keys=None, ), { "materials_manager": sentinel.materials_manager, @@ -615,6 +660,7 @@ def test_process_cli_request_source_contains_directory_nonrecursive(tmpdir, patc algorithm=None, frame_length=sentinel.frame_length, max_length=None, + max_encrypted_data_keys=None, ), { "materials_manager": sentinel.materials_manager, @@ -630,6 +676,7 @@ def test_process_cli_request_source_contains_directory_nonrecursive(tmpdir, patc algorithm="AES_256_GCM_IV12_TAG16_HKDF_SHA384_ECDSA_P384", frame_length=None, max_length=None, + max_encrypted_data_keys=None, ), { "materials_manager": sentinel.materials_manager, @@ -664,6 +711,7 @@ def patch_for_cli(mocker): discovery_partition=sentinel.discovery_partition, decode=sentinel.decode_input, encode=sentinel.encode_output, + buffer=sentinel.buffer_output, ) mocker.patch.object(aws_encryption_sdk_cli, "setup_logger") mocker.patch.object(aws_encryption_sdk_cli, "build_crypto_materials_manager_from_args") diff --git a/tox.ini b/tox.ini index ec58212..56ca73b 100644 --- a/tox.ini +++ b/tox.ini @@ -34,7 +34,7 @@ envlist = # coverage :: Runs code coverage, failing the build if coverage is below the configured threshold [testenv] -passenv = +passenv = # Identifies AWS KMS key id to use in integration tests AWS_ENCRYPTION_SDK_PYTHON_INTEGRATION_TEST_AWS_KMS_KEY_ID \ # Pass through AWS credentials @@ -310,7 +310,6 @@ passenv = TWINE_PASSWORD commands = {[testenv:build]commands} - twine upload --skip-existing {toxinidir}/dist/* [testenv:release-private] basepython = python3 @@ -328,6 +327,7 @@ commands = {[testenv:release-base]commands} # Omitting an explicit repository will cause twine to use the repository # specified in the environment variable + twine upload --skip-existing {toxinidir}/dist/* [testenv:test-release] basepython = python3 @@ -337,6 +337,7 @@ passenv = {[testenv:release-base]passenv} commands = {[testenv:release-base]commands} + twine upload --skip-existing --repository testpypi {toxinidir}/dist/* [testenv:release] basepython = python3 @@ -347,3 +348,4 @@ passenv = whitelist_externals = unset commands = {[testenv:release-base]commands} + twine upload --skip-existing --repository pypi {toxinidir}/dist/*