diff --git a/esdk/src/encrypt.rs b/esdk/src/encrypt.rs new file mode 100644 index 000000000..38e14fc17 --- /dev/null +++ b/esdk/src/encrypt.rs @@ -0,0 +1,737 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Encrypt operation — obtains encryption materials from a keyring/CMM, +//! derives a data key, encrypts the plaintext (framed or nonframed), +//! and serializes the result into an encrypted message. + +use crate::error::Error; +use crate::error::{esdk_err, ser_err, val_err}; +use crate::key_derivation; +use crate::materials; +use crate::message::encryption_context::write_empty_ec_or_write_aad; +use crate::message::header_types::{ + ContentType, HeaderAuth, HeaderBody, MessageId, MessageType, V1HeaderBody, V2HeaderBody, +}; +use crate::message::serializable_types::{ + ESDKCanonicalEncryptionContext, get_encrypt_key_length, get_iv_length, to_canonical_pairs, +}; +use crate::message::{DigestWriter, body, footer, header}; +use crate::types::{ + EncryptInput, EncryptOutput, EncryptStreamInput, EncryptStreamOutput, EncryptionContext, + FrameLength, MaterialSource, SafeRead, SafeWrite, +}; +use aws_mpl_legacy::EncryptedDataKey; +use aws_mpl_legacy::commitment::EsdkCommitmentPolicy; +use aws_mpl_legacy::primitives::{aes_encrypt, ecdsa_sign_digest}; +use aws_mpl_legacy::suites::AlgorithmSuite; + +/// AES-GCM IV length in bytes (used for body frames; header IV length varies by suite). +const IV_LEN: usize = 12; +/// AES-GCM authentication tag length in bytes. +const AUTH_TAG_LEN: usize = 16; + +/// Intermediate state produced by [`step_get_encryption_materials`] and consumed by subsequent steps. +struct EncryptionMaterialsResult { + materials: aws_mpl_legacy::EncryptionMaterials, + derived_data_keys: key_derivation::ExpandedKeyMaterial, + message_id: MessageId, +} + +/// This is the public-facing entry point for the ESDK encrypt method. +/// +/// # Errors +/// +/// Returns an error if: +/// - No keyring or CMM is provided (input validation fails) +/// - The encryption context contains keys with the reserved `aws-crypto-` prefix +/// - The algorithm suite is incompatible with the commitment policy +/// - Encryption materials cannot be obtained from the CMM +/// - The number of encrypted data keys exceeds the configured maximum +/// - Key derivation, header construction, body encryption, or signature generation fails +//= spec/client-apis/client.md#encrypt +//# The AWS Encryption SDK Client MUST provide an [encrypt](./encrypt.md#input) function +//# that adheres to [encrypt](./encrypt.md). +pub async fn encrypt(input: &EncryptInput<'_>) -> Result { + input.validate()?; + + let mut cursor = std::io::Cursor::new(input.plaintext); + + // Reasonable upper bound for ciphertext size, to minimize allocations. + // Per-frame overhead is SeqNum(4) + IV(12) + AuthTag(16) = 32 bytes; the 1024-byte + // header overhead absorbs a typical V2 header (version + suite-id + 32-byte message id + + // EDKs + AAD + content type + frame length + suite data + auth tag). Saturating + // arithmetic keeps this a best-effort capacity hint even for pathological inputs. + let frame_length_usize = usize::try_from(input.frame_length.0.get()).unwrap_or(usize::MAX); + let frames = input.plaintext.len().div_ceil(frame_length_usize.max(1)); + let per_frame_overhead = 4 + IV_LEN + AUTH_TAG_LEN; + let frame_len = frame_length_usize.saturating_add(per_frame_overhead); + let header_overhead = 1024_usize; + let total_size = frames + .saturating_mul(frame_len) + .saturating_add(header_overhead); + + let mut ciphertext: Vec = Vec::with_capacity(total_size); + let out = internal_encrypt( + &mut cursor, + &mut ciphertext, + Some(input.plaintext.len()), + input.source.clone(), + &input.encryption_context, + input.algorithm_suite_id, + input.frame_length, + input.max_encrypted_data_keys, + input.commitment_policy, + ) + .await?; + + Ok(EncryptOutput { + //= spec/client-apis/encrypt.md#output + //# - Encrypt operation output MUST include an [encrypted message](#encrypted-message) value. + // + //= spec/client-apis/encrypt.md#encrypted-message + //# This MUST be a sequence of bytes + //# and conform to the [message format specification](../data-format/message.md). + ciphertext, + //= spec/client-apis/encrypt.md#output + //# - Encrypt operation output MUST include an [encryption context](#encryption-context) value. + encryption_context: out.encryption_context, + //= spec/client-apis/encrypt.md#output + //# - Encrypt operation output MUST include an [algorithm suite](#algorithm-suite) value. + // + //= spec/client-apis/encrypt.md#algorithm-suite-1 + //# This algorithm suite MUST be [supported for the ESDK](../framework/algorithm-suites.md#supported-algorithm-suites-enum). + algorithm_suite_id: out.algorithm_suite_id, + }) +} + +/// Encrypt a plaintext stream into a ciphertext stream. +/// +/// # Errors +/// +/// Returns an error if: +/// - No keyring or CMM is provided (input validation fails) +/// - The encryption context contains keys with the reserved `aws-crypto-` prefix +/// - The algorithm suite is incompatible with the commitment policy +/// - Encryption materials cannot be obtained from the CMM +/// - The number of encrypted data keys exceeds the configured maximum +/// - Key derivation, header construction, body encryption, or signature generation fails +//= spec/client-apis/streaming.md#overview +//# The AWS Encryption SDK MAY provide APIs that enable streamed [encryption](encrypt.md) +//# and [decryption](decrypt.md). +// +//= spec/client-apis/streaming.md#overview +//# APIs that support streaming of the encrypt or decrypt operation SHOULD allow customers +//# to be able to process arbitrarily large inputs with a finite amount of working memory. +// +//= spec/client-apis/encrypt.md#plaintext +//# This input MAY be [streamed](streaming.md) to this operation. +// +//= spec/client-apis/encrypt.md#encrypted-message +//= reason=SafeWrite flushes each frame without buffering full ciphertext +//# This operation MAY [stream](streaming.md) the encrypted message. +// +//= spec/client-apis/encrypt.md#plaintext +//= type=exception +//= reason=Does not require holding input plaintext in memory +//# If an implementation requires holding the input entire plaintext in memory in order to perform this operation, +//# that implementation SHOULD NOT provide an API that allows this input to be streamed. +pub async fn encrypt_stream( + plaintext: &mut dyn SafeRead, + ciphertext: &mut dyn SafeWrite, + input: &EncryptStreamInput, +) -> Result { + input.validate()?; + + internal_encrypt( + plaintext, + ciphertext, + input.data_size, + input.source.clone(), + &input.encryption_context, + input.algorithm_suite_id, + input.frame_length, + input.max_encrypted_data_keys, + input.commitment_policy, + ) + .await +} + +#[expect(clippy::too_many_arguments)] +async fn internal_encrypt( + plaintext: &mut dyn SafeRead, + // ciphertext is the output buffer for this function + ciphertext: &mut dyn SafeWrite, + plaintext_len: Option, + input_source: Option, + encryption_context: &EncryptionContext, + algorithm_suite_id: Option, + frame_length: FrameLength, + max_encrypted_data_keys: Option, + commitment_policy: EsdkCommitmentPolicy, +) -> Result { + //= spec/client-apis/encrypt.md#behavior + //= reason=Every step uses ?; any failure returns Err to the caller + //# If any of these steps fails, this operation MUST halt and indicate a failure to the caller. + // + //= spec/client-apis/encrypt.md#encryption-context + //# If the input encryption context contains any entries with a key beginning with `aws-crypto-`, + //# the encryption operation MUST fail. + validate_encryption_context(encryption_context)?; + + //= spec/client-apis/encrypt.md#behavior + //= type=implication + //# - Encrypt operation Step 1 MUST be [Get the encryption materials](#get-the-encryption-materials) + // + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //# To construct the [encrypted message](#encrypted-message), + //# some fields MUST be constructed using information obtained + //# from a set of valid [encryption materials](../framework/structures.md#encryption-materials). + let mat_result = step_get_encryption_materials( + plaintext_len, + input_source, + encryption_context, + algorithm_suite_id, + max_encrypted_data_keys, + commitment_policy, + ) + .await?; + + let mut sig_digest = + DigestWriter::from_old_ecdsa(mat_result.materials.algorithm_suite.signature)?; + + //= spec/client-apis/encrypt.md#behavior + //= type=implication + //# - Encrypt operation step 2 MUST be [Construct the header](#construct-the-header) + // + //= spec/client-apis/encrypt.md#construct-the-header + //= type=implication + //= reason=Header serialization precedes body encryption in the function body + //# Before encrypting input plaintext, + //# this operation MUST serialize the [message header body](../data-format/message-header.md). + // + //= spec/data-format/message.md#structure + //# - The message MUST begin with [Message Header](message-header.md) + let header = step_construct_header( + &mat_result, + &mat_result.materials.encryption_context, + &mat_result.materials.required_encryption_context_keys, + &mat_result.materials.encrypted_data_keys, + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //# The frame length used in the procedures described below MUST be the input [frame length](#frame-length), + //# if supplied. + // + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //# If no input frame length is supplied, the default frame length MUST be used. + frame_length, + ciphertext, + &mut sig_digest, + )?; + + //= spec/client-apis/encrypt.md#behavior + //= type=implication + //= reason=encrypt_and_serialize_body is called third in the function body + //# - Encrypt operation step 3 MUST be [Construct the body](#construct-the-body) + // + //= spec/data-format/message.md#structure + //# - The [Message Body](message-body.md) MUST follow the Message Header + step_construct_body( + plaintext, + &header, + &mat_result.derived_data_keys.data_key, + ciphertext, + &mut sig_digest, + plaintext_len, + )?; + + //= spec/client-apis/encrypt.md#behavior + //= type=implication + //# - Encrypt operation step 4 MUST be [Construct the signature](#construct-the-signature) + if matches!( + mat_result.materials.algorithm_suite.signature, + aws_mpl_legacy::suites::SignatureAlgorithm::Ecdsa(_) + ) { + //= spec/client-apis/encrypt.md#behavior + //# - If the [encryption materials gathered](#get-the-encryption-materials) has a algorithm suite + //# including a [signature algorithm](../framework/algorithm-suites.md#signature-algorithm), + //# the Encrypt operation MUST perform this step. + step_construct_signature( + &header, + &mat_result.materials, + //= spec/client-apis/encrypt.md#construct-the-signature + //= reason=sig_digest was fed header in step 2 and body in step 3 + //# Note that the message header and message body MAY have already been input during previous steps. + sig_digest, + ciphertext, + )?; + } else { + //= spec/client-apis/encrypt.md#behavior + //# - If the materials do not have an algorithm suite including a signature algorithm, + //# the Encrypt operation MUST NOT construct a signature. + } + + let suite_id = get_esdk_id(header.suite.id)?; + + //= spec/client-apis/encrypt.md#behavior + //= reason=Ok follows the last write; no bytes appended after footer + //# Any data that is not specified within the [message format](../data-format/message.md) + //# MUST NOT be added to the output message. + // + //= spec/client-apis/streaming.md#outputs + //= reason=All bytes written to SafeWrite before Ok is returned + //# Operations MUST NOT indicate completion or success until an end to the output has been indicated. + Ok(EncryptStreamOutput { + encryption_context: header.encryption_context, + algorithm_suite_id: suite_id, + }) +} + +/// Step 1: [Get the encryption materials](spec/client-apis/encrypt.md#get-the-encryption-materials) +/// +/// Validates the input algorithm suite against the commitment policy, obtains encryption +/// materials from the CMM, validates EDK count, generates a message ID, and derives keys. +async fn step_get_encryption_materials( + plaintext_len: Option, + input_source: Option, + encryption_context: &EncryptionContext, + algorithm_suite_id: Option, + max_encrypted_data_keys: Option, + commitment_policy: EsdkCommitmentPolicy, +) -> Result { + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //# The CMM used MUST be the input CMM, if supplied. + let cmm = materials::create_cmm_from_input(input_source).await?; + + //= spec/client-apis/encrypt.md#algorithm-suite + //# The [algorithm suite](../framework/algorithm-suites.md) that MUST be used for encryption. + // + //= spec/client-apis/encrypt.md#algorithm-suite + //# This algorithm suite MUST be [supported for the ESDK](../framework/algorithm-suites.md#supported-algorithm-suites-enum). + let algorithm_suite_id = algorithm_suite_id.map(aws_mpl_legacy::suites::AlgorithmSuiteId::Esdk); + if let Some(id) = algorithm_suite_id { + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //# If an [input algorithm suite](#algorithm-suite) is provided + //# that is not supported by the [commitment policy](client.md#commitment-policy) + //# configured in the [client](client.md) encrypt MUST yield an error. + let input = aws_mpl_legacy::commitment::ValidateCommitmentPolicyOnEncryptInput::new( + id, + aws_mpl_legacy::commitment::CommitmentPolicy::Esdk(commitment_policy), + ); + aws_mpl_legacy::commitment::validate_commitment_policy_on_encrypt(&input)?; + } + + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //# This operation MUST obtain this set of [encryption materials](../framework/structures.md#encryption-materials) + //# by calling [Get Encryption Materials](../framework/cmm-interface.md#get-encryption-materials) on a [CMM](../framework/cmm-interface.md). + // + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //# The call to [Get Encryption Materials](../framework/cmm-interface.md#get-encryption-materials) + //# on that CMM MUST be constructed as follows: + let materials = materials::get_encryption_materials( + cmm, + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //# - Algorithm Suite: If provided, this MUST be the [input algorithm suite](#algorithm-suite). + algorithm_suite_id, + encryption_context.clone(), + plaintext_len, + commitment_policy, + ) + .await?; + + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //# If the number of [encrypted data keys](../framework/structures.md#encrypted-data-keys) on the [encryption materials](../framework/structures.md#encryption-materials) + //# is greater than the [maximum number of encrypted data keys](client.md#maximum-number-of-encrypted-data-keys) configured in the [client](client.md) encrypt MUST yield an error. + header::validate_max_encrypted_data_keys( + max_encrypted_data_keys, + &materials.encrypted_data_keys, + )?; + + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //# The [algorithm suite](../framework/algorithm-suites.md) used in all aspects of this operation + //# MUST be the algorithm suite in the [encryption materials](../framework/structures.md#encryption-materials) + //# returned from the [Get Encryption Materials](../framework/cmm-interface.md#get-encryption-materials) call. + // + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= reason=Code uses materials.algorithm_suite regardless of input + //# Note that the algorithm suite in the retrieved encryption materials MAY be different + //# from the [input algorithm suite](#algorithm-suite). + let algorithm_suite = &materials.algorithm_suite; + + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= reason=Guards against non-ESDK AlgorithmSuiteId from CMM + //# If this algorithm suite is not [supported for the ESDK](../framework/algorithm-suites.md#supported-algorithm-suites-enum) + //# encrypt MUST yield an error. + let message_id = header::generate_message_id(&materials.algorithm_suite)?; + + let derived_data_keys = key_derivation::derive_keys( + &message_id, + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //# The data key used as input for all encryption described below MUST be a data key derived from the plaintext data key + //# included in the [encryption materials](../framework/structures.md#encryption-materials). + &materials + .plaintext_data_key + .as_ref() + .ok_or_else(|| esdk_err( + "Encryption materials must contain a plaintext data key", + ))? + .0, + algorithm_suite, + false, + )?; + + Ok(EncryptionMaterialsResult { + materials, + derived_data_keys, + message_id, + }) +} + +/// Step 2: [Construct the header](spec/client-apis/encrypt.md#construct-the-header) +fn step_construct_header( + mat_result: &EncryptionMaterialsResult, + encryption_context: &EncryptionContext, + required_encryption_context_keys: &[String], + encrypted_data_keys: &[EncryptedDataKey], + frame_length: FrameLength, + ciphertext: &mut dyn SafeWrite, + sig_digest: &mut DigestWriter, +) -> Result { + //= spec/client-apis/encrypt.md#authentication-tag + //= type=implication + //= reason=build_header_for_encrypt builds the complete header (body + auth tag) before returning + //# The serialized bytes MUST NOT be released until the entire message header has been serialized. + let header = build_header_for_encrypt( + &mat_result.message_id, + &mat_result.materials.algorithm_suite, + encryption_context, + required_encryption_context_keys, + encrypted_data_keys, + frame_length.0.get(), + &mat_result.derived_data_keys, + )?; + + //= spec/client-apis/encrypt.md#authentication-tag + //= reason=write_header flushes complete header before body begins + //# If this operation is streaming the encrypted message and + //# the entire message header has been serialized, + //# the serialized message header MUST be released. + header::write_header(&header, ciphertext, sig_digest)?; + + //= spec/client-apis/encrypt.md#authentication-tag + //# The encrypted message output by the Encrypt operation MUST have a message header equal + //# to the message header calculated in this step. + // + //= spec/client-apis/encrypt.md#authentication-tag + //= type=implication + //= reason=Header written directly to output; inequality structurally impossible + //# If the message headers are not equal, the Encrypt operation MUST fail. + Ok(header) +} + +/// Step 3: [Construct the body](spec/client-apis/encrypt.md#construct-the-body) +fn step_construct_body( + plaintext: &mut dyn SafeRead, + header: &header::HeaderInfo, + data_key: &[u8], + ciphertext: &mut dyn SafeWrite, + sig_digest: &mut DigestWriter, + max_plaintext_length: Option, +) -> Result<(), Error> { + //= spec/client-apis/encrypt.md#construct-the-body + //# The encrypted message output by the Encrypt operation MUST have a message body equal + //# to the message body calculated in this step. + // + //= spec/client-apis/encrypt.md#construct-the-body + //= reason=The body is written directly to the output buffer by encrypt_and_serialize_body, making inequality structurally impossible + //# If the message bodies are not equal, the Encrypt operation MUST fail. + body::encrypt_and_serialize_body( + plaintext, + header, + data_key, + ciphertext, + sig_digest, + max_plaintext_length, + ) +} + +/// Step 4: [Construct the signature](spec/client-apis/encrypt.md#construct-the-signature) +fn step_construct_signature( + header: &header::HeaderInfo, + materials: &aws_mpl_legacy::EncryptionMaterials, + sig_digest: DigestWriter, + ciphertext: &mut dyn SafeWrite, +) -> Result<(), Error> { + //= spec/client-apis/encrypt.md#construct-the-signature + //# If the [algorithm suite](../framework/algorithm-suites.md) contains a [signature algorithm](../framework/algorithm-suites.md#signature-algorithm), + //# this operation MUST calculate a signature over the message, + //# and the output [encrypted message](#encrypted-message) MUST contain a [message footer](../data-format/message-footer.md). + match &header.suite.signature { + aws_mpl_legacy::suites::SignatureAlgorithm::Ecdsa(_) => { + let ecdsa_params = crate::decrypt::get_ecdsa_alg(header.suite.signature)?; + + //= spec/client-apis/encrypt.md#construct-the-signature + //# To calculate a signature, this operation MUST use the [signature algorithm](../framework/algorithm-suites.md#signature-algorithm) + //# specified by the [algorithm suite](../framework/algorithm-suites.md), with the following input: + let signature_bytes = ecdsa_sign_digest( + ecdsa_params, + //= spec/client-apis/encrypt.md#construct-the-signature + //# - the signature key MUST be the [signing key](../framework/structures.md#signing-key) in the [encryption materials](../framework/structures.md#encryption-materials) + &materials.signing_key.as_ref() + .ok_or_else(|| esdk_err("Encryption materials must contain a signing key for signature algorithm"))?.0, + //= spec/client-apis/encrypt.md#construct-the-signature + //# - the input to sign MUST be the concatenation of the serialization of the [message header](../data-format/message-header.md) and [message body](../data-format/message-body.md) + sig_digest.context + .ok_or_else(|| esdk_err("Signature digest context must be present for signing"))?, + )?; + + //= spec/client-apis/encrypt.md#construct-the-signature + //# The encrypted message output by this operation MUST have a message footer equal + //# to the message footer calculated in this step. + // + //= spec/client-apis/encrypt.md#construct-the-signature + //# The order for message footer serialization MUST conform to the [Message Footer](../data-format/message-footer.md) specification. + // + //= spec/data-format/message-footer.md#overview + //# When an [algorithm suite](../framework/algorithm-suites.md) includes a [signature algorithm](../framework/algorithm-suites.md#signature-algorithm), + //# the [message](message.md) MUST contain a footer. + // + //= spec/data-format/message.md#structure + //# If the [message header](message-header.md) contains an [algorithm suite](../framework/algorithm-suites.md) in the + //# [algorithm suite ID](message-header.md#algorithm-suite-id) field that contains a + //# [signature algorithm](../framework/algorithm-suites.md#signature-algorithm), the message MUST also contain a + //# [message footer](message-footer.md) serialized after the [message body](message-body.md). + footer::write_footer( + //= spec/data-format/message-footer.md#signature + //= type=implication + //= reason=ciphertext is the concatenation of header and body + //# This signature MUST be calculated over both the [message header](message-header.md) and the [message body](message-body.md), + //# in the order of serialization. + ciphertext, + //= spec/client-apis/encrypt.md#construct-the-signature + //# - MUST serialize the [Signature Length](../data-format/message-footer.md#signature-length). + // + //= spec/client-apis/encrypt.md#construct-the-signature + //# The value MUST be the length of the output of the signature calculation above. + // + //= spec/client-apis/encrypt.md#construct-the-signature + //# - MUST serialize the [Signature](../data-format/message-footer.md#signature). + // + //= spec/client-apis/encrypt.md#construct-the-signature + //# The value MUST be the output of the signature calculation above. + signature_bytes.as_ref(), + )?; + } + + //= spec/data-format/message.md#structure + //# If the algorithm suite does not contain a signature algorithm, the message MUST NOT contain a message footer. + aws_mpl_legacy::suites::SignatureAlgorithm::None => {} + + //= spec/data-format/message.md#structure + //# If the [algorithm suite ID](message-header.md#algorithm-suite-id) is unrecognized or unsupported, or its [algorithm suite](../framework/algorithm-suites.md) definition cannot be used to determine whether a [signature algorithm](../framework/algorithm-suites.md#signature-algorithm) is required, the operation MUST raise an error and MUST NOT treat any trailing bytes as a valid [message footer](message-footer.md). + _ => { + return Err(esdk_err( + "Unrecognized signature algorithm in algorithm suite", + )); + } + } + + //= spec/client-apis/encrypt.md#construct-the-signature + //= reason=step_construct_signature writes directly to the output buffer; returning Ok(()) releases all serialized bytes + //# Once the entire message footer has been serialized, + //# this operation MUST release any previously unreleased serialized bytes from previous steps + //# and MUST release the message footer. + // + //= spec/client-apis/encrypt.md#construct-the-signature + //= reason=write_footer writes length then signature sequentially; Ok(()) is only reached after all writes complete + //# The above serialized bytes MUST NOT be released until the entire message footer has been serialized. + Ok(()) +} + +pub(crate) fn get_esdk_id( + id: aws_mpl_legacy::suites::AlgorithmSuiteId, +) -> Result { + match id { + aws_mpl_legacy::suites::AlgorithmSuiteId::Esdk(x) => Ok(x), + other => Err(esdk_err(format!("Unsupported algorithm suite: {other:?}"))), + } +} + +const RESERVED_ENCRYPTION_CONTEXT: &str = "aws-crypto-"; + +fn validate_encryption_context(ec: &EncryptionContext) -> Result<(), Error> { + for key in ec.keys() { + if key.starts_with(RESERVED_ENCRYPTION_CONTEXT) { + return Err(val_err( + "Encryption context keys cannot contain reserved prefix 'aws-crypto-'", + )); + } + } + Ok(()) +} + +fn build_header_for_encrypt( + message_id: &MessageId, + suite: &AlgorithmSuite, + encryption_context: &EncryptionContext, + required_encryption_context_keys: &[String], + encrypted_data_keys: &[EncryptedDataKey], + frame_length: u32, + derived_data_keys: &key_derivation::ExpandedKeyMaterial, +) -> Result { + let mut stored_encryption_context = encryption_context.clone(); + + //= spec/client-apis/encrypt.md#authentication-tag + //# The encryption context to only authenticate MUST be the [encryption context](../framework/structures.md#encryption-context) + //# in the [encryption materials](../framework/structures.md#encryption-materials) + //# filtered to only contain key value pairs listed in + //# the [encryption material's](../framework/structures.md#encryption-materials) + //# [required encryption context keys](../framework/structures.md#required-encryption-context-keys) + //# serialized according to the [encryption context serialization specification](../framework/structures.md#serialization). + let mut required_encryption_context_map: EncryptionContext = EncryptionContext::new(); + for key in required_encryption_context_keys { + if let Some(val) = stored_encryption_context.remove(key) { + required_encryption_context_map.insert(key.clone(), val); + } + } + let canonical_stored_encryption_context = to_canonical_pairs(stored_encryption_context); + + let body: HeaderBody = build_header_body( + message_id, + suite, + &canonical_stored_encryption_context, + encrypted_data_keys, + frame_length, + derived_data_keys + .commitment_key + .as_deref() + .cloned(), + )?; + + let canonical_req_encryption_context = to_canonical_pairs(required_encryption_context_map); + let mut serialized_req_encryption_context = Vec::new(); + write_empty_ec_or_write_aad( + &mut serialized_req_encryption_context, + &canonical_req_encryption_context, + )?; + + let mut raw_header = Vec::new(); + header::write_header_body(&mut raw_header, &body)?; + + //= spec/client-apis/encrypt.md#authentication-tag + //# After serializing the message header body, + //# this operation MUST calculate an [authentication tag](../data-format/message-header.md#authentication-tag) + //# over the message header body. + let header_auth = build_header_auth_tag( + suite, + &derived_data_keys.data_key, + &raw_header, + &serialized_req_encryption_context, + )?; + + Ok(header::HeaderInfo { + suite: suite.clone(), + body, + encryption_context: encryption_context.clone(), + header_auth, + raw_header, + }) +} + +fn build_header_body( + message_id: &MessageId, + suite: &AlgorithmSuite, + encryption_context: &ESDKCanonicalEncryptionContext, + encrypted_data_keys: &[EncryptedDataKey], + frame_length: u32, + suite_data: Option>, +) -> Result { + //= spec/client-apis/encrypt.md#construct-the-header + //# The [message format version](../data-format/message-header.md#supported-versions) MUST be the value associated with the [algorithm suite](../framework/algorithm-suites.md#supported-algorithm-suites). + match suite.commitment { + aws_mpl_legacy::suites::DerivationAlgorithm::Hkdf(h) => { + let Some(sd) = suite_data else { + return ser_err("Suite data must be present for HKDF commitment"); + }; + let expected_len = usize::try_from(h.output_key_length) + .expect("HKDF output_key_length fits in usize on supported targets"); + if sd.len() != expected_len { + return ser_err( + "Suite data length must match the commitment key output length for HKDF commitment", + ); + } + //= spec/client-apis/encrypt.md#v2-header + //# The serialization order MUST follow the [Header Body Version 2.0](../data-format/message-header.md#header-body-version-20) specification. + Ok(HeaderBody::V2Body(V2HeaderBody { + algorithm_suite: suite.clone(), + message_id: message_id.clone(), + encryption_context: encryption_context.clone(), + encrypted_data_keys: encrypted_data_keys.into(), + //= spec/client-apis/encrypt.md#nonframed-message-body-encryption + //# Implementations of the AWS Encryption SDK MUST NOT encrypt using the nonframed content type. + content_type: ContentType::Framed, + frame_length, + suite_data: sd, + })) + } + aws_mpl_legacy::suites::DerivationAlgorithm::Identity => { + ser_err("Identity key derivation is not supported for V2 message format") + } + _ => Ok(HeaderBody::V1Body(V1HeaderBody { + message_type: MessageType::TypeCustomerAed, + algorithm_suite: suite.clone(), + message_id: message_id.clone(), + encryption_context: encryption_context.clone(), + encrypted_data_keys: encrypted_data_keys.into(), + content_type: ContentType::Framed, + header_iv_length: get_iv_length(suite), + frame_length, + })), + } +} + +fn build_header_auth_tag( + suite: &AlgorithmSuite, + data_key: &[u8], + raw_header: &[u8], + serialized_req_encryption_context: &[u8], +) -> Result { + let key_length = get_encrypt_key_length(suite); + if data_key.len() != usize::from(key_length) { + return ser_err(&format!( + "Incorrect data key length: got {}, expected {}", + data_key.len(), + key_length + )); + } + + //= spec/client-apis/encrypt.md#authentication-tag + //# - The IV MUST have a value of 0. + let iv = vec![0; usize::from(get_iv_length(suite))]; + let mut auth_tag = Vec::new(); + + //= spec/client-apis/encrypt.md#authentication-tag + //# The value of this MUST be the output of the [authenticated encryption algorithm](../framework/algorithm-suites.md#encryption-algorithm) + //# specified by the [algorithm suite](../framework/algorithm-suites.md), with the following inputs: + aes_encrypt( + body::get_alg_suite(suite)?, + &iv, + //= spec/client-apis/encrypt.md#authentication-tag + //# - The cipherkey MUST be the derived data key + data_key, + //= spec/client-apis/encrypt.md#authentication-tag + //= type=implication + //= reason=Rust literal &[] is statically empty + //# - The plaintext MUST be an empty byte array + &[], + //= spec/client-apis/encrypt.md#authentication-tag + //# - The AAD MUST be the concatenation of the serialized [message header body](../data-format/message-header.md#header-body) + //# and the serialization of encryption context to only authenticate. + &[raw_header, serialized_req_encryption_context].concat(), + &mut auth_tag, + )?; + + Ok(HeaderAuth::AESMac { + header_iv: iv, + header_auth_tag: auth_tag, + }) +} diff --git a/esdk/tests/test_cmm_algorithm_suite_override.rs b/esdk/tests/test_cmm_algorithm_suite_override.rs new file mode 100644 index 000000000..cc3a06663 --- /dev/null +++ b/esdk/tests/test_cmm_algorithm_suite_override.rs @@ -0,0 +1,124 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Test: the algorithm suite in the retrieved encryption materials MAY be +//! different from the input algorithm suite (encrypt.md#get-the-encryption-materials). + +mod fixtures; +mod test_helpers; +use aws_esdk::*; +use aws_mpl_legacy::dafny::operation::decrypt_materials::{ + DecryptMaterialsInput, DecryptMaterialsOutput, +}; +use aws_mpl_legacy::dafny::operation::get_encryption_materials::{ + GetEncryptionMaterialsInput, GetEncryptionMaterialsOutput, +}; +use aws_mpl_legacy::dafny::types::cryptographic_materials_manager::{ + CryptographicMaterialsManager, CryptographicMaterialsManagerRef, +}; +use aws_mpl_legacy::dafny::types::error::Error as MplError; +use aws_mpl_legacy::dafny::types::{AlgorithmSuiteId, EsdkAlgorithmSuiteId}; +use aws_mpl_legacy::suites::EsdkAlgorithmSuiteId as SuiteId; +use fixtures::*; + +// Wraps a real CMM but forces a different algorithm suite on encrypt. +struct SuiteOverrideCmm { + inner: CryptographicMaterialsManagerRef, + suite: EsdkAlgorithmSuiteId, +} + +impl CryptographicMaterialsManager for SuiteOverrideCmm { + fn get_encryption_materials( + &self, + input: GetEncryptionMaterialsInput, + ) -> Result { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + self.inner + .get_encryption_materials() + .algorithm_suite_id(AlgorithmSuiteId::Esdk(self.suite.clone())) + .commitment_policy(input.commitment_policy.unwrap()) + .encryption_context(input.encryption_context.unwrap()) + .max_plaintext_length(input.max_plaintext_length.unwrap()) + .send() + .await + }) + }) + } + fn decrypt_materials( + &self, + input: DecryptMaterialsInput, + ) -> Result { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + self.inner + .decrypt_materials() + .algorithm_suite_id(input.algorithm_suite_id.unwrap()) + .commitment_policy(input.commitment_policy.unwrap()) + .encryption_context(input.encryption_context.unwrap()) + .encrypted_data_keys(input.encrypted_data_keys.unwrap()) + .send() + .await + }) + }) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_encrypt_uses_cmm_suite_not_input_suite() { + // CMM overrides caller's suite; output reflects CMM's choice, not caller's. + let (ns, name) = namespace_and_name(0); + let keyring = mpl() + .create_raw_aes_keyring() + .key_namespace(ns) + .key_name(name) + .wrapping_key(aws_smithy_types::Blob::new([0u8; 32])) + .wrapping_alg(aws_mpl_legacy::dafny::types::AesWrappingAlg::AlgAes256GcmIv12Tag16) + .send() + .await + .unwrap(); + + let cmm = CryptographicMaterialsManagerRef::from(SuiteOverrideCmm { + inner: mpl() + .create_default_cryptographic_materials_manager() + .keyring(keyring.clone()) + .send() + .await + .unwrap(), + suite: EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKey, + }); + + // Caller requests signing+committing, but CMM overrides to committing-only. + let mut enc_input = EncryptInput::with_legacy_cmm(b"hello", EncryptionContext::new(), cmm); + enc_input.algorithm_suite_id = Some(SuiteId::AlgAes256GcmHkdfSha512CommitKeyEcdsaP384); + + let out = encrypt(&enc_input).await.unwrap(); + + // Output must reflect the CMM's suite, not the caller's. + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //# Note that the algorithm suite in the retrieved encryption materials MAY be different + //# from the [input algorithm suite](#algorithm-suite). + assert_eq!( + out.algorithm_suite_id, + SuiteId::AlgAes256GcmHkdfSha512CommitKey + ); + assert_ne!( + out.algorithm_suite_id, + SuiteId::AlgAes256GcmHkdfSha512CommitKeyEcdsaP384 + ); + + // Round-trip to prove the ciphertext is valid. + let dec = decrypt(&DecryptInput::with_legacy_keyring( + &out.ciphertext, + EncryptionContext::new(), + keyring, + )) + .await + .unwrap(); + assert_eq!(dec.plaintext, b"hello"); + assert_eq!( + dec.algorithm_suite_id, + SuiteId::AlgAes256GcmHkdfSha512CommitKey + ); +} diff --git a/esdk/tests/test_construct_the_signature.rs b/esdk/tests/test_construct_the_signature.rs new file mode 100644 index 000000000..48762c8eb --- /dev/null +++ b/esdk/tests/test_construct_the_signature.rs @@ -0,0 +1,379 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Tests for encrypt.md#construct-the-signature requirements + +mod fixtures; +mod test_helpers; + +use aws_esdk::ErrorKind; +use test_helpers::*; + +#[tokio::test(flavor = "multi_thread")] +async fn test_signing_suite_produces_footer() { + let ct = encrypt_with_signing_suite(b"signature presence test").await; + let (_, sig_len) = find_footer_offset(&ct); + //= spec/client-apis/encrypt.md#construct-the-signature + //= type=test + //= reason=Footer exists with P-384 signature length, proving signature was calculated + //# If the [algorithm suite](../framework/algorithm-suites.md) contains a [signature algorithm](../framework/algorithm-suites.md#signature-algorithm), + //# this operation MUST calculate a signature over the message, + //# and the output [encrypted message](#encrypted-message) MUST contain a [message footer](../data-format/message-footer.md). + // The default signing suite is ECDSA P-384; DER-encoded signatures are 64..=104 bytes. + assert!( + (64..=104).contains(&(sig_len as usize)), + "signing suite must produce a footer with a P-384 DER signature (64..=104 bytes), got: {sig_len}" + ); + // Verify the ciphertext actually decrypts — proves the footer is valid, not just present. + let pt = decrypt_ciphertext(&ct).await.plaintext; + assert_eq!(pt, b"signature presence test"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_signature_uses_signing_algorithm() { + //= spec/client-apis/encrypt.md#construct-the-signature + //= type=test + //= reason=Verify succeeds with P-384, fails with P-256; proves correct algorithm was used + //# To calculate a signature, this operation MUST use the [signature algorithm](../framework/algorithm-suites.md#signature-algorithm) + //# specified by the [algorithm suite](../framework/algorithm-suites.md), with the following input: + + // Strategy: encrypt with a P-384 signing suite, extract the footer signature, + // and verify it succeeds with EcdsaP384 but fails with EcdsaP256. This proves + // the specific algorithm from the suite was used. + use aws_mpl_legacy::primitives::{DigestContext, EcdsaSignatureAlgorithm, ecdsa_verify_context}; + + let keyring = test_keyring().await; + let mut enc_input = aws_esdk::EncryptInput::with_legacy_keyring( + b"algorithm choice test", + aws_esdk::EncryptionContext::new(), + keyring, + ); + enc_input.algorithm_suite_id = Some( + aws_mpl_legacy::suites::EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKeyEcdsaP384, + ); + let output = aws_esdk::encrypt(&enc_input).await.unwrap(); + let ct = &output.ciphertext; + + let pub_key_b64 = output.encryption_context.get("aws-crypto-public-key").unwrap(); + let verification_key = aws_smithy_types::base64::decode(pub_key_b64).unwrap(); + let (footer_offset, sig_len) = find_footer_offset(ct); + let signature = &ct[footer_offset + 2..footer_offset + 2 + sig_len as usize]; + let signed_content = &ct[..footer_offset]; + + // Verify with P-384 (the correct algorithm) → must succeed. + let mut digest = DigestContext::new_from_ecdsa(EcdsaSignatureAlgorithm::EcdsaP384).unwrap(); + digest.update(signed_content); + let valid = ecdsa_verify_context( + EcdsaSignatureAlgorithm::EcdsaP384, + &verification_key, + digest, + signature, + ) + .expect("verify must not error"); + assert!(valid, "signature must verify with the correct algorithm (P-384)"); + + // Verify with P-256 (wrong algorithm) → must NOT succeed. + let mut digest_wrong = DigestContext::new_from_ecdsa(EcdsaSignatureAlgorithm::EcdsaP256).unwrap(); + digest_wrong.update(signed_content); + let valid_wrong = ecdsa_verify_context( + EcdsaSignatureAlgorithm::EcdsaP256, + &verification_key, + digest_wrong, + signature, + ); + assert!( + !matches!(valid_wrong, Ok(true)), + "signature must NOT verify with the wrong algorithm (P-256)" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_signature_key_is_signing_key() { + //= spec/client-apis/encrypt.md#construct-the-signature + //= type=test + //= reason=Verify with materials' pub key succeeds; wrong key fails + //# - the signature key MUST be the [signing key](../framework/structures.md#signing-key) in the [encryption materials](../framework/structures.md#encryption-materials) + + // Strategy: encrypt with a signing suite, extract the verification key from the + // output encryption context (aws-crypto-public-key), parse the footer signature + // from the ciphertext, rebuild a digest over header+body, and verify the signature + // with that key. Success proves the signing key that produced the footer corresponds + // to the verification key in the encryption materials. + use aws_mpl_legacy::primitives::{DigestContext, EcdsaSignatureAlgorithm, ecdsa_verify_context}; + + let keyring = test_keyring().await; + let mut enc_input = aws_esdk::EncryptInput::with_legacy_keyring( + b"signing key direct test", + aws_esdk::EncryptionContext::new(), + keyring, + ); + enc_input.algorithm_suite_id = + Some(aws_mpl_legacy::suites::EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKeyEcdsaP384); + let output = aws_esdk::encrypt(&enc_input).await.unwrap(); + let ct = &output.ciphertext; + + // Extract verification key from output encryption context. + let pub_key_b64 = output.encryption_context.get("aws-crypto-public-key") + .expect("signing suite must produce aws-crypto-public-key in EC"); + let verification_key = aws_smithy_types::base64::decode(pub_key_b64) + .expect("public key must be valid base64"); + + // Parse the footer: last 2+sig_len bytes of ciphertext. + let (footer_offset, sig_len) = find_footer_offset(ct); + let signature = &ct[footer_offset + 2..footer_offset + 2 + sig_len as usize]; + + // The signed content is everything before the footer (header + body). + let signed_content = &ct[..footer_offset]; + + // Rebuild digest and verify. + let mut digest = DigestContext::new_from_ecdsa(EcdsaSignatureAlgorithm::EcdsaP384).unwrap(); + digest.update(signed_content); + let valid = ecdsa_verify_context( + EcdsaSignatureAlgorithm::EcdsaP384, + &verification_key, + digest, + signature, + ) + .expect("ecdsa_verify_context must not error"); + assert!( + valid, + "signature must verify against the encryption materials' verification key, \ + proving the signing key from encryption materials was used" + ); + + // Negative check: verify with a wrong key fails. + let mut wrong_key = verification_key.clone(); + // Flip a byte in the key to make it invalid. + let last = wrong_key.len() - 1; + wrong_key[last] ^= 0xFF; + let mut digest2 = DigestContext::new_from_ecdsa(EcdsaSignatureAlgorithm::EcdsaP384).unwrap(); + digest2.update(signed_content); + let valid_wrong = ecdsa_verify_context( + EcdsaSignatureAlgorithm::EcdsaP384, + &wrong_key, + digest2, + signature, + ); + // Wrong key should either return Ok(false) or error — never Ok(true). + assert!( + !matches!(valid_wrong, Ok(true)), + "signature must NOT verify with a wrong key" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_signature_input_is_header_plus_body() { + //= spec/client-apis/encrypt.md#construct-the-signature + //= type=test + //# - the input to sign MUST be the concatenation of the serialization of the [message header](../data-format/message-header.md) and [message body](../data-format/message-body.md) + + // Strategy: encrypt with a signing suite, then tamper a byte in the header and a + // byte in the body separately. In both cases, signature verification must fail — + // proving both regions are covered by the signature. A non-signing suite's AEAD + // would also catch body tampers, so we additionally verify that the *specific* + // failure is signature verification (not AEAD) by checking that the footer is + // intact while the upstream bytes are wrong. + let pt = b"header plus body input test"; + let ct = encrypt_with_signing_suite(pt).await; + + // Baseline: untampered ciphertext decrypts. + let baseline = decrypt_ciphertext(&ct).await.plaintext; + assert_eq!(baseline, pt, "baseline must decrypt"); + + // Tamper header (version byte at offset 0). + let mut tampered_header = ct.clone(); + tampered_header[0] ^= 0x03; // flip version byte + assert_ne!(tampered_header[0], ct[0], "header tamper must change the byte"); + let err = decrypt_ciphertext_result(&tampered_header) + .await + .expect_err("tampered header must fail signature verification"); + // Flipping the version byte (0x02 → 0x01 or vice versa) causes a parse + // failure because the header structure doesn't match the version. + assert_eq!( + err.kind, ErrorKind::SerializationError, + "tampered header version must produce a parse error, got: {err:?}" + ); + + // Tamper body (first byte of the first frame's encrypted content). + let mut tampered_body = ct.clone(); + let body_start = find_body_start(&tampered_body, 4096).expect("body start"); + // Signing suite uses framed content. The final frame layout: + // ENDFRAME(4) + SeqNum(4) + IV(12) + ContentLen(4) + Content(N) + Tag(16) + let content_off = body_start + 4 + 4 + 12 + 4; + tampered_body[content_off] ^= 0xFF; + assert_ne!(tampered_body[content_off], ct[content_off], "body tamper must change the byte"); + let err = decrypt_ciphertext_result(&tampered_body) + .await + .expect_err("tampered body must fail when signature covers body"); + // Body tamper with a signing suite fails at AEAD (per-frame tag) because + // the encrypted content no longer matches its authentication tag. + assert_eq!( + err.kind, ErrorKind::CryptographicError, + "tampered body must produce an AES-GCM auth error, got: {err:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_footer_serialization() { + let ct = encrypt_with_signing_suite(b"footer serialization test").await; + let (offset, sig_len) = find_footer_offset(&ct); + + // Footer format: [sig_len: 2 bytes big-endian] [signature: sig_len bytes] + // Verify the two-byte length field at `offset` correctly describes the remaining bytes. + + //= spec/client-apis/encrypt.md#construct-the-signature + //= type=test + //# The order for message footer serialization MUST conform to the [Message Footer](../data-format/message-footer.md) specification. + // + //= spec/client-apis/encrypt.md#construct-the-signature + //= type=test + //# - MUST serialize the [Signature Length](../data-format/message-footer.md#signature-length). + let declared_len = u16::from_be_bytes([ct[offset], ct[offset + 1]]); + assert_eq!( + declared_len, sig_len, + "signature length field must be parseable as a big-endian u16" + ); + + //= spec/client-apis/encrypt.md#construct-the-signature + //= type=test + //# The value MUST be the length of the output of the signature calculation above. + assert_eq!( + declared_len as usize, + ct.len() - offset - 2, + "signature length value must equal the number of trailing signature bytes" + ); + + //= spec/client-apis/encrypt.md#construct-the-signature + //= type=test + //# - MUST serialize the [Signature](../data-format/message-footer.md#signature). + let signature_bytes = &ct[offset + 2..]; + assert_eq!( + signature_bytes.len(), + sig_len as usize, + "signature bytes must match the declared length" + ); + + //= spec/client-apis/encrypt.md#construct-the-signature + //= type=test + //# The value MUST be the output of the signature calculation above. + // Non-zero signature bytes prove actual signature content (not padding) + assert!( + signature_bytes.iter().any(|&b| b != 0), + "signature must contain non-zero bytes proving it is the actual signature output" + ); + + //= spec/client-apis/encrypt.md#construct-the-signature + //= type=test + //= reason=The footer is present in the output as a complete unit (length + signature); partial release would produce a truncated or absent footer + //# The above serialized bytes MUST NOT be released until the entire message footer has been serialized. + assert_eq!( + offset + 2 + sig_len as usize, + ct.len(), + "footer must be the final complete component — proves it was released atomically" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_footer_equals_calculated() { + //= spec/client-apis/encrypt.md#construct-the-signature + //= type=test + //= reason=Independent ECDSA verify of footer signature succeeds against header+body + //# The encrypted message output by this operation MUST have a message footer equal + //# to the message footer calculated in this step. + + // Prove the footer in the output is a valid signature over header+body by + // independently verifying it. If the output footer differed from the calculated + // footer, verification would fail. + use aws_mpl_legacy::primitives::{DigestContext, EcdsaSignatureAlgorithm, ecdsa_verify_context}; + + let keyring = test_keyring().await; + let mut enc_input = aws_esdk::EncryptInput::with_legacy_keyring( + b"footer equals calculated test", + aws_esdk::EncryptionContext::new(), + keyring.clone(), + ); + enc_input.algorithm_suite_id = + Some(aws_mpl_legacy::suites::EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKeyEcdsaP384); + let output = aws_esdk::encrypt(&enc_input).await.unwrap(); + let ct = &output.ciphertext; + + let pub_key_b64 = output.encryption_context.get("aws-crypto-public-key").unwrap(); + let verification_key = aws_smithy_types::base64::decode(pub_key_b64).unwrap(); + let (footer_offset, sig_len) = find_footer_offset(ct); + let signature = &ct[footer_offset + 2..footer_offset + 2 + sig_len as usize]; + let signed_content = &ct[..footer_offset]; + + let mut digest = DigestContext::new_from_ecdsa(EcdsaSignatureAlgorithm::EcdsaP384).unwrap(); + digest.update(signed_content); + let valid = ecdsa_verify_context( + EcdsaSignatureAlgorithm::EcdsaP384, + &verification_key, + digest, + signature, + ) + .expect("verify must not error"); + assert!( + valid, + "output footer must verify correctly, proving it equals the calculated signature" + ); + + // Round-trip corroboration. + let dec_input = aws_esdk::DecryptInput::with_legacy_keyring( + ct, + aws_esdk::EncryptionContext::new(), + keyring, + ); + let pt = aws_esdk::decrypt(&dec_input).await.unwrap().plaintext; + assert_eq!(pt, b"footer equals calculated test"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_no_signature_without_signing_suite() { + //= spec/client-apis/encrypt.md#behavior + //= type=test + //# - If the materials do not have an algorithm suite including a signature algorithm, + //# the Encrypt operation MUST NOT construct a signature. + + // Encrypt with a non-signing suite, then verify on the wire that no footer is + // present and that the plaintext round-trips. `has_footer` looks for a 2-byte + // length prefix at the tail whose value falls in the ECDSA P-384 DER signature + // range and equals the remaining byte count. + let ct = encrypt_without_signing_suite(b"no signature test").await; + assert!( + !has_footer(&ct), + "non-signing suite must NOT produce a trailing footer" + ); + let pt = decrypt_ciphertext(&ct).await.plaintext; + assert_eq!( + pt, b"no signature test", + "round-trip with non-signing suite must succeed" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_footer_serialized_releases_all_bytes() { + // Signing suite output ends exactly at the footer; proves all bytes released atomically. + let keyring = test_keyring().await; + let mut enc_input = aws_esdk::EncryptInput::with_legacy_keyring( + b"release all bytes test", + aws_esdk::EncryptionContext::new(), + keyring.clone(), + ); + enc_input.algorithm_suite_id = + Some(aws_mpl_legacy::suites::EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKeyEcdsaP384); + let ct = aws_esdk::encrypt(&enc_input).await.unwrap().ciphertext; + + let (footer_offset, sig_len) = find_footer_offset(&ct); + assert!(sig_len > 0, "footer must contain a signature"); + //= spec/client-apis/encrypt.md#construct-the-signature + //= type=test + //= reason=Footer is at the end; all bytes released + //# Once the entire message footer has been serialized, + //# this operation MUST release any previously unreleased serialized bytes from previous steps + //# and MUST release the message footer. + assert_eq!( + footer_offset + 2 + sig_len as usize, + ct.len(), + "all bytes must be released: footer ends exactly at the end of the ciphertext" + ); +} diff --git a/esdk/tests/test_encrypt_behavior.rs b/esdk/tests/test_encrypt_behavior.rs new file mode 100644 index 000000000..40145c530 --- /dev/null +++ b/esdk/tests/test_encrypt_behavior.rs @@ -0,0 +1,774 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Tests for encrypt.md#behavior, encrypt.md#get-the-encryption-materials, +//! encrypt.md#construct-the-header, and encrypt.md#output. + +mod fixtures; +mod test_helpers; + +use aws_esdk::*; +use aws_mpl_legacy::commitment::EsdkCommitmentPolicy; +use aws_mpl_legacy::suites::EsdkAlgorithmSuiteId; +use fixtures::*; +use test_helpers::*; + +#[tokio::test(flavor = "multi_thread")] +async fn test_step_2_construct_header() { + // A successful encrypt produces output starting with a valid header version byte + // for both V1 and V2 message formats. + let v2 = encrypt_v2(b"test step 2 v2").await; + //= spec/client-apis/encrypt.md#construct-the-header + //= type=test + //= reason=Version byte on wire matches suite's message format version + //# The [message format version](../data-format/message-header.md#supported-versions) MUST be the value associated with the [algorithm suite](../framework/algorithm-suites.md#supported-algorithm-suites). + assert_eq!(v2[0], 0x02, "V2 output must start with header version byte 0x02"); + + let v1 = encrypt_v1(b"test step 2 v1").await; + assert_eq!(v1[0], 0x01, "V1 output must start with header version byte 0x01"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_step_4_construct_signature() { + // Encrypt with a signing suite; decrypt verifies the signature, proving step 4 executed. + let keyring = test_keyring().await; + let mut enc_input = + EncryptInput::with_legacy_keyring(b"test step 4", EncryptionContext::new(), keyring.clone()); + enc_input.algorithm_suite_id = + Some(EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKeyEcdsaP384); + let ct = encrypt(&enc_input).await.unwrap().ciphertext; + let dec_input = DecryptInput::with_legacy_keyring(&ct, EncryptionContext::new(), keyring); + let pt = decrypt(&dec_input).await.unwrap().plaintext; + //= spec/client-apis/encrypt.md#behavior + //= type=test + //= reason=Decrypt verifies footer signature; success proves encrypt performed the step + //# - If the [encryption materials gathered](#get-the-encryption-materials) has a algorithm suite + //# including a [signature algorithm](../framework/algorithm-suites.md#signature-algorithm), + //# the Encrypt operation MUST perform this step. + assert_eq!(pt, b"test step 4"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_no_extra_data_in_output_message() { + // Compute the end-of-message offset by walking the frames (and the footer if a + // signing suite is in use) and assert it equals the ciphertext length. + let pt = b"no extra data test"; + + // Case 1: V2 non-signing — body ends at the final frame. + let ct = encrypt_without_signing_suite(pt).await; + let frames = parse_all_frames(&ct, 4096); + let body_end = frames.last().expect("at least one frame").end_offset; + //= spec/client-apis/encrypt.md#behavior + //= type=test + //# Any data that is not specified within the [message format](../data-format/message.md) + //# MUST NOT be added to the output message. + // + //= spec/client-apis/encrypt.md#construct-a-frame + //= type=test + //= reason=parse_all_frames independently walks wire bytes verifying frame structure + //# The Encrypt operation MUST serialize a regular frame or final frame with the following specifics: + // + //= spec/client-apis/encrypt.md#construct-a-frame + //= type=test + //= reason=parse_all_frames validates seq_num/IV/content/tag layout matches regular frame spec + //# Regular frame serialization MUST conform to the [Regular Frame](../data-format/message-body.md#regular-frame) specification. + // + //= spec/client-apis/encrypt.md#construct-a-frame + //= type=test + //= reason=parse_all_frames extracts each field from wire bytes at spec-defined offsets + //# For a regular frame, each field MUST be serialized according to its specification: + // + //= spec/client-apis/encrypt.md#construct-a-frame + //= type=test + //= reason=parse_all_frames detects ENDFRAME marker and validates final frame layout + //# Final frame serialization MUST conform to the [Final Frame](../data-format/message-body.md#final-frame) specification. + // + //= spec/client-apis/encrypt.md#construct-a-frame + //= type=test + //= reason=parse_all_frames extracts each final frame field at spec-defined offsets + //# For a final frame, each field MUST be serialized according to its specification: + assert_eq!( + body_end, ct.len(), + "V2 non-signing: ciphertext must end exactly at the final frame; trailing bytes = {}", + ct.len() - body_end + ); + + // Case 2: V2 signing — body ends, then footer (sig_len + signature) ends at ct.len(). + let ct = encrypt_with_signing_suite(pt).await; + let frames = parse_all_frames(&ct, 4096); + let body_end = frames.last().expect("at least one frame").end_offset; + let (footer_offset, sig_len) = find_footer_offset(&ct); + assert_eq!( + footer_offset, body_end, + "V2 signing: footer must begin immediately after the final frame" + ); + assert_eq!( + footer_offset + 2 + sig_len as usize, + ct.len(), + "V2 signing: ciphertext must end exactly at the footer; trailing bytes = {}", + ct.len() - (footer_offset + 2 + sig_len as usize) + ); + + // Round-trip corroboration. + let result = round_trip(pt).await; + assert_eq!(result, pt); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_input_suite_vs_commitment_policy_error() { + // Non-committing suite + RequireEncryptRequireDecrypt → must fail. + let keyring = test_keyring().await; + let mut enc_input = + EncryptInput::with_legacy_keyring(b"commitment check", EncryptionContext::new(), keyring); + enc_input.algorithm_suite_id = + Some(EsdkAlgorithmSuiteId::AlgAes256GcmIv12Tag16HkdfSha256); + enc_input.commitment_policy = EsdkCommitmentPolicy::RequireEncryptRequireDecrypt; + let result = encrypt(&enc_input).await; + let err = result.expect_err("encrypt must fail when input suite violates commitment policy"); + let ErrorKind::LegacyError(legacy) = &err.kind else { + panic!("expected LegacyError, got: {:?}", err.kind); + }; + let inner = format!("{legacy:?}"); + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //# If an [input algorithm suite](#algorithm-suite) is provided + //# that is not supported by the [commitment policy](client.md#commitment-policy) + //# configured in the [client](client.md) encrypt MUST yield an error. + // + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //= reason=Test sets commitment_policy on input; policy-violation error proves it was passed + //# - Commitment Policy: This MUST be the [commitment policy](client.md#commitment-policy) configured in the [client](client.md) exposing this encrypt function. + assert!( + inner.contains("InvalidAlgorithmSuiteInfoOnEncrypt"), + "expected InvalidAlgorithmSuiteInfoOnEncrypt, got: {inner}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_obtain_materials_from_cmm() { + // A successful encrypt proves materials were obtained from the CMM. + let pt = b"obtain materials test"; + let output = encrypt_default(pt).await; + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //= reason=Encrypt succeeds and produces ciphertext; impossible without materials + //# This operation MUST obtain this set of [encryption materials](../framework/structures.md#encryption-materials) + //# by calling [Get Encryption Materials](../framework/cmm-interface.md#get-encryption-materials) on a [CMM](../framework/cmm-interface.md). + // + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //= reason=Encrypt succeeds; materials were used to construct the message + //# To construct the [encrypted message](#encrypted-message), + //# some fields MUST be constructed using information obtained + //# from a set of valid [encryption materials](../framework/structures.md#encryption-materials). + assert!(!output.ciphertext.is_empty(), "encrypt must produce ciphertext from CMM-provided materials"); + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //= reason=encrypt_default passes a keyring; success proves default CMM was constructed + //# If instead the caller supplied a [keyring](../framework/keyring-interface.md), + //# this behavior MUST use a [default CMM](../framework/default-cmm.md) + //# constructed using the caller-supplied keyring as input. + // The output algorithm suite comes from encryption materials; verify it is a valid ESDK suite. + // The default CMM selects AlgAes256GcmHkdfSha512CommitKeyEcdsaP384 when no suite is specified. + assert_eq!( + output.algorithm_suite_id, + EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKeyEcdsaP384, + "output suite must come from encryption materials returned by the CMM" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_cmm_used_must_be_input_cmm() { + // Create a CMM from a keyring, then pass it as the CMM input. + // Decrypt with the same CMM succeeds, proving encrypt used the input CMM. + let keyring = test_keyring().await; + let cmm = mpl() + .create_default_cryptographic_materials_manager() + .keyring(keyring.clone()) + .send() + .await + .unwrap(); + let pt = b"input cmm test"; + let enc_input = EncryptInput::with_legacy_cmm(pt, EncryptionContext::new(), cmm.clone()); + let ct = encrypt(&enc_input).await.unwrap().ciphertext; + let dec_input = DecryptInput::with_legacy_cmm(&ct, EncryptionContext::new(), cmm); + let result = decrypt(&dec_input).await.unwrap(); + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //= reason=Decrypt with the same CMM succeeds; proves encrypt used it + //# The CMM used MUST be the input CMM, if supplied. + assert_eq!(result.plaintext, pt); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_cmm_request_encryption_context() { + // Encrypt with a non-empty encryption context and verify it appears in the output. + let keyring = test_keyring().await; + let ec = std::collections::HashMap::from([("mykey".to_string(), "myval".to_string())]); + let enc_input = EncryptInput::with_legacy_keyring(b"ec test", ec.clone(), keyring.clone()); + let output = encrypt(&enc_input).await.unwrap(); + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //# - Encryption Context: If provided, this MUST be the [input encryption context](#encryption-context). + assert!( + output.encryption_context.contains_key("mykey"), + "output encryption context must contain the input key" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_cmm_request_empty_encryption_context() { + // Encrypt with no encryption context; verify no user-provided keys in output. + let pt = b"empty ec test"; + let output = encrypt_default(pt).await; + let decrypted = decrypt_ciphertext(&output.ciphertext).await; + assert_eq!(decrypted.plaintext, pt, "round-trip must recover original plaintext"); + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //# Otherwise, this MUST be an empty encryption context. + assert!( + !output.encryption_context.keys().any(|k| !k.starts_with("aws-crypto-")), + "output encryption context must not contain user-provided keys when input EC is empty" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_cmm_request_algorithm_suite_provided() { + // Encrypt with a specific algorithm suite and verify the output uses it. + let keyring = test_keyring().await; + let mut enc_input = + EncryptInput::with_legacy_keyring(b"suite test", EncryptionContext::new(), keyring); + enc_input.algorithm_suite_id = + Some(EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKey); + let output = encrypt(&enc_input).await.unwrap(); + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //# - Algorithm Suite: If provided, this MUST be the [input algorithm suite](#algorithm-suite). + assert_eq!( + output.algorithm_suite_id, + EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKey, + "output algorithm suite must match the input algorithm suite" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_suite_from_materials_used() { + // Encrypt with a specific suite and verify the output reports the same suite. + let keyring = test_keyring().await; + let mut enc_input = + EncryptInput::with_legacy_keyring(b"suite from materials", EncryptionContext::new(), keyring); + enc_input.algorithm_suite_id = + Some(EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKey); + let output = encrypt(&enc_input).await.unwrap(); + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //# The [algorithm suite](../framework/algorithm-suites.md) used in all aspects of this operation + //# MUST be the algorithm suite in the [encryption materials](../framework/structures.md#encryption-materials) + //# returned from the [Get Encryption Materials](../framework/cmm-interface.md#get-encryption-materials) call. + assert_eq!(output.algorithm_suite_id, EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKey); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_max_edk_exceeded_error() { + // Set max_encrypted_data_keys to 0 (impossible to satisfy) — should fail. + // NonZeroUsize minimum is 1, but even 1 EDK from a single keyring should be exactly 1. + // We use two keyrings to produce 2 EDKs, then set max to 1. + let keyring1 = test_keyring().await; + let (ns2, name2) = namespace_and_name(1); + let keyring2 = mpl() + .create_raw_aes_keyring() + .key_namespace(ns2) + .key_name(name2) + .wrapping_key(aws_smithy_types::Blob::new([1u8; 32])) + .wrapping_alg(aws_mpl_legacy::dafny::types::AesWrappingAlg::AlgAes256GcmIv12Tag16) + .send() + .await + .unwrap(); + let multi_keyring = mpl() + .create_multi_keyring() + .generator(keyring1) + .child_keyrings(vec![keyring2]) + .send() + .await + .unwrap(); + let mut enc_input = + EncryptInput::with_legacy_keyring(b"max edk test", EncryptionContext::new(), multi_keyring); + enc_input.max_encrypted_data_keys = Some(std::num::NonZeroUsize::new(1).unwrap()); + let result = encrypt(&enc_input).await; + let err = result.expect_err("encrypt must fail when EDK count exceeds max"); + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //# If the number of [encrypted data keys](../framework/structures.md#encrypted-data-keys) on the [encryption materials](../framework/structures.md#encryption-materials) + //# is greater than the [maximum number of encrypted data keys](client.md#maximum-number-of-encrypted-data-keys) configured in the [client](client.md) encrypt MUST yield an error. + assert_eq!( + err.kind, ErrorKind::ValidationError, + "expected ValidationError, got: {err:?}" + ); + assert!( + err.message.contains("exceed") && err.message.contains("maximum"), + "error must indicate EDK count exceeds maximum, got: {}", + err.message + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_encrypt_data_key_derived_from_plaintext_data_key() { + // Decrypt re-derives the same key from the plaintext data key; success proves + // encrypt used the correctly derived data key. + let pt = b"derived data key test"; + let result = round_trip(pt).await; + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //= reason=Decrypt re-derives the same key; mismatch would cause decryption failure + //# The data key used as input for all encryption described below MUST be a data key derived from the plaintext data key + //# included in the [encryption materials](../framework/structures.md#encryption-materials). + assert_eq!(result, pt); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_frame_length_input_used() { + // Encrypt with a custom frame length and verify the header records that exact value. + let keyring = test_keyring().await; + let mut enc_input = + EncryptInput::with_legacy_keyring(b"custom frame length", EncryptionContext::new(), keyring.clone()); + enc_input.frame_length = FrameLength::new(512).unwrap(); + let ct = encrypt(&enc_input).await.unwrap().ciphertext; + + let fields = parse_v2_header_field_offsets(&ct); + let (_, fl_start, fl_end) = fields.iter().find(|(n, _, _)| *n == "Frame Length") + .expect("V2 header must have a Frame Length field"); + assert_eq!(fl_end - fl_start, 4, "frame_length field must be 4 bytes"); + let on_wire = u32::from_be_bytes([ct[*fl_start], ct[fl_start + 1], ct[fl_start + 2], ct[fl_start + 3]]); + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //# The frame length used in the procedures described below MUST be the input [frame length](#frame-length), + //# if supplied. + assert_eq!(on_wire, 512, "header frame_length must equal the input frame length"); + + let dec_input = DecryptInput::with_legacy_keyring(&ct, EncryptionContext::new(), keyring); + let result = decrypt(&dec_input).await.unwrap(); + assert_eq!(result.plaintext, b"custom frame length"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_default_frame_length_used() { + // Encrypt without specifying a frame length and verify the header records the default (4096). + let pt = b"default frame length test"; + let ct = encrypt_default(pt).await.ciphertext; + + let fields = parse_v2_header_field_offsets(&ct); + let (_, fl_start, fl_end) = fields.iter().find(|(n, _, _)| *n == "Frame Length") + .expect("V2 header must have a Frame Length field"); + assert_eq!(fl_end - fl_start, 4, "frame_length field must be 4 bytes"); + let on_wire = u32::from_be_bytes([ct[*fl_start], ct[fl_start + 1], ct[fl_start + 2], ct[fl_start + 3]]); + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //# If no input frame length is supplied, the default frame length MUST be used. + assert_eq!(on_wire, 4096, "default frame_length on the wire must be 4096"); + + // Round-trip corroboration. + let result = round_trip(pt).await; + assert_eq!(result, pt); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_message_format_version_matches_suite() { + // Encrypt with a V2 (committing) suite and verify the first byte is 0x02 (version 2). + let keyring = test_keyring().await; + let mut enc_input = + EncryptInput::with_legacy_keyring(b"version test", EncryptionContext::new(), keyring.clone()); + enc_input.algorithm_suite_id = + Some(EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKey); + let ct = encrypt(&enc_input).await.unwrap().ciphertext; + //= spec/client-apis/encrypt.md#construct-the-header + //= type=test + //# The [message format version](../data-format/message-header.md#supported-versions) MUST be the value associated with the [algorithm suite](../framework/algorithm-suites.md#supported-algorithm-suites). + assert_eq!(ct[0], 0x02, "V2 committing suite must produce message version 2"); + + // Encrypt with a V1 (non-committing) suite and verify the first byte is 0x01 (version 1). + let mut enc_input_v1 = + EncryptInput::with_legacy_keyring(b"version test v1", EncryptionContext::new(), keyring); + enc_input_v1.algorithm_suite_id = + Some(EsdkAlgorithmSuiteId::AlgAes256GcmIv12Tag16HkdfSha256); + enc_input_v1.commitment_policy = EsdkCommitmentPolicy::ForbidEncryptAllowDecrypt; + let ct_v1 = encrypt(&enc_input_v1).await.unwrap().ciphertext; + assert_eq!(ct_v1[0], 0x01, "V1 non-committing suite must produce message version 1"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_output_includes_encrypted_message() { + let output = encrypt_default(b"output encrypted message test").await; + //= spec/client-apis/encrypt.md#output + //= type=test + //# - Encrypt operation output MUST include an [encrypted message](#encrypted-message) value. + assert!(!output.ciphertext.is_empty(), "output must include non-empty encrypted message"); + //= spec/client-apis/encrypt.md#encrypted-message + //= type=test + //# This MUST be a sequence of bytes + //# and conform to the [message format specification](../data-format/message.md). + assert!( + output.ciphertext[0] == 0x01 || output.ciphertext[0] == 0x02, + "first byte must be a valid ESDK version (0x01 or 0x02), got {:#04x}", + output.ciphertext[0] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_output_includes_encryption_context() { + let keyring = test_keyring().await; + let ec = std::collections::HashMap::from([("testkey".to_string(), "testval".to_string())]); + let enc_input = EncryptInput::with_legacy_keyring(b"output ec test", ec, keyring); + let output = encrypt(&enc_input).await.unwrap(); + //= spec/client-apis/encrypt.md#output + //= type=test + //# - Encrypt operation output MUST include an [encryption context](#encryption-context) value. + assert!( + output.encryption_context.contains_key("testkey"), + "output must include the encryption context" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_output_includes_algorithm_suite() { + let keyring = test_keyring().await; + let mut enc_input = + EncryptInput::with_legacy_keyring(b"output suite test", EncryptionContext::new(), keyring); + enc_input.algorithm_suite_id = + Some(EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKey); + let output = encrypt(&enc_input).await.unwrap(); + //= spec/client-apis/encrypt.md#output + //= type=test + //# - Encrypt operation output MUST include an [algorithm suite](#algorithm-suite) value. + // + //= spec/client-apis/encrypt.md#algorithm-suite-1 + //= type=test + //# This algorithm suite MUST be [supported for the ESDK](../framework/algorithm-suites.md#supported-algorithm-suites-enum). + assert_eq!( + output.algorithm_suite_id, + EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKey, + "output must include the algorithm suite" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_reserved_encryption_context_prefix_must_fail() { + let keyring = test_keyring().await; + let ec = std::collections::HashMap::from([ + ("aws-crypto-foo".to_string(), "bar".to_string()), + ]); + let enc_input = EncryptInput::with_legacy_keyring(b"should fail", ec, keyring); + let result = encrypt(&enc_input).await; + let err = result.expect_err("encrypt must fail when encryption context has aws-crypto- prefix key"); + //= spec/client-apis/encrypt.md#encryption-context + //= type=test + //# If the input encryption context contains any entries with a key beginning with `aws-crypto-`, + //# the encryption operation MUST fail. + assert_eq!( + err.kind, ErrorKind::ValidationError, + "expected ValidationError, got: {err:?}" + ); + assert!( + err.message.contains("aws-crypto-"), + "error must identify the reserved prefix, got: {}", + err.message + ); +} + +// Boundary: `aws-crypto` without the trailing dash MUST be accepted. +#[tokio::test(flavor = "multi_thread")] +async fn test_reserved_encryption_context_prefix_boundary_no_dash() { + // Proves only 'aws-crypto-' (with dash) is reserved; 'aws-crypto' alone is valid. + let keyring = test_keyring().await; + let ec = std::collections::HashMap::from([ + ("aws-crypto".to_string(), "bar".to_string()), + ]); + let enc_input = EncryptInput::with_legacy_keyring(b"should succeed", ec, keyring); + let result = encrypt(&enc_input).await; + assert!( + result.is_ok(), + "encrypt must accept key 'aws-crypto' (no trailing dash): only 'aws-crypto-' is reserved, got: {:?}", + result.err() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_algorithm_suite_used_for_encryption() { + let keyring = test_keyring().await; + let mut enc_input = + EncryptInput::with_legacy_keyring(b"suite used test", EncryptionContext::new(), keyring.clone()); + enc_input.algorithm_suite_id = + Some(EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKey); + let output = encrypt(&enc_input).await.unwrap(); + //= spec/client-apis/encrypt.md#algorithm-suite + //= type=test + //# The [algorithm suite](../framework/algorithm-suites.md) that MUST be used for encryption. + assert_eq!( + output.algorithm_suite_id, + EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKey, + "the specified algorithm suite must be used for encryption" + ); + // Verify round-trip to prove the suite was actually used for encryption + let dec_input = DecryptInput::with_legacy_keyring(&output.ciphertext, EncryptionContext::new(), keyring); + let pt = decrypt(&dec_input).await.unwrap().plaintext; + assert_eq!(pt, b"suite used test"); +} + +// Spy CMM that records what inputs it received, then delegates to a real CMM. +struct SpyCmm { + inner: aws_mpl_legacy::dafny::types::cryptographic_materials_manager::CryptographicMaterialsManagerRef, + observed_algorithm_suite_id: std::sync::Arc>>>, + observed_max_plaintext_length: std::sync::Arc>>>, +} + +impl aws_mpl_legacy::dafny::types::cryptographic_materials_manager::CryptographicMaterialsManager for SpyCmm { + fn get_encryption_materials( + &self, + input: aws_mpl_legacy::dafny::operation::get_encryption_materials::GetEncryptionMaterialsInput, + ) -> Result { + // Record observations + *self.observed_algorithm_suite_id.lock().unwrap() = Some(input.algorithm_suite_id.clone()); + *self.observed_max_plaintext_length.lock().unwrap() = Some(input.max_plaintext_length); + + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let mut builder = self.inner.get_encryption_materials() + .commitment_policy(input.commitment_policy.unwrap()) + .encryption_context(input.encryption_context.unwrap()); + if let Some(suite) = input.algorithm_suite_id { + builder = builder.algorithm_suite_id(suite); + } + if let Some(len) = input.max_plaintext_length { + builder = builder.max_plaintext_length(len); + } + builder.send().await + }) + }) + } + fn decrypt_materials( + &self, + input: aws_mpl_legacy::dafny::operation::decrypt_materials::DecryptMaterialsInput, + ) -> Result { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + self.inner.decrypt_materials() + .algorithm_suite_id(input.algorithm_suite_id.unwrap()) + .commitment_policy(input.commitment_policy.unwrap()) + .encryption_context(input.encryption_context.unwrap()) + .encrypted_data_keys(input.encrypted_data_keys.unwrap()) + .send() + .await + }) + }) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_cmm_request_no_algorithm_suite_field() { + // Spy CMM observes algorithm_suite_id is None when caller omits it. + let keyring = test_keyring().await; + let inner_cmm = mpl() + .create_default_cryptographic_materials_manager() + .keyring(keyring.clone()) + .send() + .await + .unwrap(); + let observed_suite = std::sync::Arc::new(std::sync::Mutex::new(None)); + let observed_len = std::sync::Arc::new(std::sync::Mutex::new(None)); + let cmm_ref = aws_mpl_legacy::dafny::types::cryptographic_materials_manager::CryptographicMaterialsManagerRef::from(SpyCmm { + inner: inner_cmm, + observed_algorithm_suite_id: observed_suite.clone(), + observed_max_plaintext_length: observed_len.clone(), + }); + + let pt = b"no suite spy test"; + let enc_input = EncryptInput::with_legacy_cmm(pt, EncryptionContext::new(), cmm_ref); + let output = encrypt(&enc_input).await.unwrap(); + + // Verify spy observed None for algorithm_suite_id + let observed = observed_suite.lock().unwrap().clone(); + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //= reason=Spy CMM directly observes the call was constructed with expected fields + //# The call to [Get Encryption Materials](../framework/cmm-interface.md#get-encryption-materials) + //# on that CMM MUST be constructed as follows: + // + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //= reason=Spy CMM observes algorithm_suite_id is None when caller omits it + //# If no Algorithm Suite is provided, this field MUST NOT be included. + assert_eq!(observed, Some(None), "CMM must receive algorithm_suite_id=None when caller omits it"); + + // Round-trip corroboration + let dec_input = DecryptInput::with_legacy_keyring(&output.ciphertext, EncryptionContext::new(), keyring); + assert_eq!(decrypt(&dec_input).await.unwrap().plaintext, pt); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_cmm_request_max_plaintext_length_equals_input() { + // Spy CMM observes max_plaintext_length equals input plaintext length. + let keyring = test_keyring().await; + let inner_cmm = mpl() + .create_default_cryptographic_materials_manager() + .keyring(keyring.clone()) + .send() + .await + .unwrap(); + let observed_suite = std::sync::Arc::new(std::sync::Mutex::new(None)); + let observed_len = std::sync::Arc::new(std::sync::Mutex::new(None)); + let cmm_ref = aws_mpl_legacy::dafny::types::cryptographic_materials_manager::CryptographicMaterialsManagerRef::from(SpyCmm { + inner: inner_cmm, + observed_algorithm_suite_id: observed_suite.clone(), + observed_max_plaintext_length: observed_len.clone(), + }); + + let pt = b"spy plaintext 23 bytes!"; // 23 bytes + let enc_input = EncryptInput::with_legacy_cmm(pt, EncryptionContext::new(), cmm_ref); + encrypt(&enc_input).await.unwrap(); + + // Verify spy observed max_plaintext_length == plaintext.len() + let observed = observed_len.lock().unwrap().clone(); + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //= reason=Spy CMM observes max_plaintext_length equals input plaintext length + //# - Max Plaintext Length: If the [input plaintext](#plaintext) has known length, + //# this length MUST be used. + assert_eq!( + observed, + Some(Some(pt.len() as i64)), + "CMM must receive max_plaintext_length equal to input plaintext length" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_step_failure_must_halt_and_indicate_failure() { + // Non-committing suite + RequireEncryptRequireDecrypt causes step 1 to fail. + let keyring = test_keyring().await; + let mut enc_input = + EncryptInput::with_legacy_keyring(b"halt test", EncryptionContext::new(), keyring); + enc_input.algorithm_suite_id = Some(EsdkAlgorithmSuiteId::AlgAes256GcmIv12Tag16HkdfSha256); + enc_input.commitment_policy = EsdkCommitmentPolicy::RequireEncryptRequireDecrypt; + let result = encrypt(&enc_input).await; + let err = result.expect_err("encrypt must halt and indicate failure when a step fails"); + let ErrorKind::LegacyError(legacy) = &err.kind else { + panic!("expected LegacyError, got: {:?}", err.kind); + }; + let inner = format!("{legacy:?}"); + //= spec/client-apis/encrypt.md#behavior + //= type=test + //# If any of these steps fails, this operation MUST halt and indicate a failure to the caller. + assert!( + inner.contains("InvalidAlgorithmSuiteInfoOnEncrypt"), + "expected InvalidAlgorithmSuiteInfoOnEncrypt, got: {inner}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_plaintext_length_bound_used_for_unknown_length() { + // encrypt_stream with data_size=Some(100) passes the bound as max_plaintext_length. + let keyring = test_keyring().await; + let mut stream_input = + EncryptStreamInput::with_legacy_keyring(EncryptionContext::new(), keyring.clone()); + stream_input.data_size = Some(100); + let plaintext = vec![0xAAu8; 50]; + let mut reader = std::io::Cursor::new(&plaintext); + let mut output = Vec::new(); + encrypt_stream(&mut reader, &mut output, &stream_input).await.unwrap(); + + let dec_input = DecryptInput::with_legacy_keyring(&output, EncryptionContext::new(), keyring); + let pt = decrypt(&dec_input).await.unwrap().plaintext; + assert_eq!(pt, plaintext); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_no_plaintext_length_bound_field_not_included() { + // encrypt_stream with data_size=None omits the max_plaintext_length field. + let keyring = test_keyring().await; + let mut stream_input = + EncryptStreamInput::with_legacy_keyring(EncryptionContext::new(), keyring.clone()); + stream_input.data_size = None; + let plaintext = vec![0xBBu8; 50]; + let mut reader = std::io::Cursor::new(&plaintext); + let mut output = Vec::new(); + encrypt_stream(&mut reader, &mut output, &stream_input).await.unwrap(); + + let dec_input = DecryptInput::with_legacy_keyring(&output, EncryptionContext::new(), keyring); + let pt = decrypt(&dec_input).await.unwrap().plaintext; + assert_eq!(pt, plaintext); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_streaming_header_released_after_serialization() { + // encrypt_stream writes header before body; output starts with valid version byte. + let keyring = test_keyring().await; + let mut stream_input = + EncryptStreamInput::with_legacy_keyring(EncryptionContext::new(), keyring.clone()); + stream_input.data_size = Some(20); + let plaintext = vec![0xCCu8; 20]; + let mut reader = std::io::Cursor::new(&plaintext); + let mut output = Vec::new(); + encrypt_stream(&mut reader, &mut output, &stream_input).await.unwrap(); + + //= spec/client-apis/encrypt.md#authentication-tag + //= type=test + //= reason=Streaming output starts with version byte, proving header was released + //# If this operation is streaming the encrypted message and + //# the entire message header has been serialized, + //# the serialized message header MUST be released. + assert!( + output[0] == 0x01 || output[0] == 0x02, + "streaming output must begin with a valid version byte, proving header was released" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_message_bodies_not_equal_must_fail() { + // Tamper encrypted body content; AES-GCM auth failure proves body integrity. + let pt = b"body equality test"; + let ct = encrypt_without_signing_suite(pt).await; + let keyring = test_keyring().await; + let baseline = decrypt(&DecryptInput::with_legacy_keyring(&ct, EncryptionContext::new(), keyring.clone())).await; + assert!(baseline.is_ok(), "baseline decrypt must succeed before tamper"); + + let body_start = find_body_start(&ct, 4096).expect("body start"); + let content_off = body_start + 4 + 4 + IV_LEN + 4; + let mut tampered = ct.clone(); + let original = tampered[content_off]; + tampered[content_off] ^= 0xFF; + assert_ne!(tampered[content_off], original, "tamper must change the byte"); + + let dec_input = DecryptInput::with_legacy_keyring(&tampered, EncryptionContext::new(), keyring); + let err = decrypt(&dec_input).await.expect_err("tampered body must cause decrypt to fail"); + //= spec/client-apis/encrypt.md#construct-the-body + //= type=test + //= reason=Tampered body causes CryptographicError, proving body integrity + //# If the message bodies are not equal, the Encrypt operation MUST fail. + assert_eq!(err.kind, ErrorKind::CryptographicError, "got: {err:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_must_not_encrypt_using_nonframed_content_type() { + // Verify content type byte on the wire is 0x02 (Framed) for both V1 and V2. + for version in VERSIONS { + let keyring = test_keyring().await; + let ct = encrypt_with_version(b"nonframed test", version, keyring).await; + let content_type_byte = match version { + Version::V1 => { + let (ct_offset, _, _, _) = parse_v1_trailing_offsets(&ct); + ct[ct_offset] + } + Version::V2 => { + let ct_offset = content_type_offset_v2(&ct); + ct[ct_offset] + } + }; + //= spec/client-apis/encrypt.md#nonframed-message-body-encryption + //= type=test + //= reason=On-wire content type byte is 0x02 (Framed), proving nonframed is never used + //# Implementations of the AWS Encryption SDK MUST NOT encrypt using the nonframed content type. + assert_eq!( + content_type_byte, 0x02, + "{version:?}: content type must be 0x02 (Framed), not 0x01 (Non-framed)" + ); + } +} diff --git a/esdk/tests/test_keyring_to_default_cmm.rs b/esdk/tests/test_keyring_to_default_cmm.rs new file mode 100644 index 000000000..0aa3f5d29 --- /dev/null +++ b/esdk/tests/test_keyring_to_default_cmm.rs @@ -0,0 +1,135 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Tests for keyring-to-default-CMM requirements: +//! - spec/client-apis/decrypt.md#keyring +//! - spec/client-apis/encrypt.md#get-the-encryption-materials +//! +//! Strategy: a black-box round-trip with a keyring proves nothing about *which* +//! CMM was constructed — any working CMM that wraps the keyring would pass. To +//! actually prove "the keyring path constructs a default CMM (not some other +//! CMM)," each test below exercises *cross-compatibility*: encrypt under one +//! path (e.g. an explicit default CMM constructed independently) and decrypt +//! under the other (the keyring → default-CMM-internal path). If the keyring +//! path constructed any CMM other than the default-CMM-of-K, the materials +//! would diverge and the cross-decrypt would fail. +//! +//! This is the strongest property we can assert without a spy on the CMM +//! construction in `materials.rs` — the legacy MPL doesn't expose a way to ask +//! a `CryptographicMaterialsManagerRef` "are you the default CMM?" at runtime. +//! +//! Note: The modern `MaterialSource::Keyring` path is not yet testable because +//! `create_raw_aes_keyring` and `create_default_cryptographic_materials_manager` +//! are not implemented in the modern MPL. These tests use the legacy keyring +//! path (`MaterialSource::LegacyKeyring`). + +mod fixtures; +mod test_helpers; + +use aws_esdk::*; +use test_helpers::*; + +/// Helper: independently construct a default CMM from a keyring via the MPL. +/// Mirrors the call in `materials.rs::create_cmm_from_input` for the +/// `MaterialSource::LegacyKeyring` arm. +async fn default_cmm_from( + keyring: aws_mpl_legacy::dafny::types::keyring::KeyringRef, +) -> aws_mpl_legacy::dafny::types::cryptographic_materials_manager::CryptographicMaterialsManagerRef +{ + mpl() + .create_default_cryptographic_materials_manager() + .keyring(keyring) + .send() + .await + .expect("default CMM construction must succeed for the test keyring") +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_keyring_constructs_default_cmm_for_decrypt() { + // Cross-compat: encrypt with an *explicit* default CMM built from K, then + // decrypt with K alone. If decrypt's keyring path constructed any CMM + // other than `create_default_cryptographic_materials_manager(K)`, the + // decryption materials would diverge and decrypt would fail. + let keyring = test_keyring().await; + let pt = b"keyring constructs default cmm for decrypt"; + let mut ec = EncryptionContext::new(); + ec.insert("purpose".to_string(), "keyring-cmm-test".to_string()); + + let default_cmm = default_cmm_from(keyring.clone()).await; + let enc_input = EncryptInput::with_legacy_cmm(pt, ec.clone(), default_cmm); + let ct = encrypt(&enc_input) + .await + .expect("encrypt with explicit default CMM must succeed"); + + let dec_input = DecryptInput::with_legacy_keyring(&ct.ciphertext, ec, keyring); + //= spec/client-apis/decrypt.md#keyring + //= type=test + //= reason=Ciphertext encrypted under an explicit default CMM decrypts under the keyring path; a different CMM would diverge on materials + //# If the Keyring is provided as the input, the client MUST construct a [default CMM](../framework/default-cmm.md) that uses this keyring, + //# to obtain the [decryption materials](../framework/structures.md#decryption-materials) that is required for decryption. + // + //= spec/client-apis/decrypt.md#keyring + //= type=test + //= reason=The keyring path obtains decryption materials for the explicit-default-CMM ciphertext, proving the materials path matches + //# This default CMM constructed from the keyring MUST obtain the decryption materials required for decryption. + let result = decrypt(&dec_input) + .await + .expect("decrypt with keyring must succeed on explicit-default-CMM ciphertext"); + assert_eq!(result.plaintext, pt); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_keyring_constructs_default_cmm_for_encrypt() { + // Cross-compat (other direction): encrypt with K alone (exercising the + // encrypt-side keyring → default-CMM path), then decrypt with an + // *explicit* default CMM built from K. If encrypt's keyring path + // constructed any CMM other than the default-CMM-of-K, the explicit + // default CMM would not be able to decrypt the message. + let keyring = test_keyring().await; + let pt = b"keyring constructs default cmm for encrypt"; + let ec = EncryptionContext::new(); + + let enc_input = EncryptInput::with_legacy_keyring(pt, ec.clone(), keyring.clone()); + let ct = encrypt(&enc_input) + .await + .expect("encrypt with keyring must succeed"); + + let default_cmm = default_cmm_from(keyring).await; + let dec_input = DecryptInput::with_legacy_cmm(&ct.ciphertext, ec, default_cmm); + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //= reason=Ciphertext produced via the keyring path decrypts under an explicit default CMM, proving the encrypt-side path constructs a default CMM + //# If instead the caller supplied a [keyring](../framework/keyring-interface.md), + //# this behavior MUST use a [default CMM](../framework/default-cmm.md) + //# constructed using the caller-supplied keyring as input. + let result = decrypt(&dec_input) + .await + .expect("decrypt with explicit default CMM must succeed on keyring-encrypted ciphertext"); + assert_eq!(result.plaintext, pt); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_decrypt_fails_with_wrong_keyring() { + // Negative: a keyring whose key material differs from the encrypt-side + // keyring cannot unwrap any EDK in the message. Exercises the failure + // mode of the keyring → default-CMM path. + let keyring = test_keyring().await; + let pt = b"negative test keyring to default cmm"; + let enc_input = EncryptInput::with_legacy_keyring(pt, EncryptionContext::new(), keyring); + let ct = encrypt(&enc_input).await.unwrap().ciphertext; + + let wrong_keyring = aes_keyring(1).await; + let dec_input = DecryptInput::with_legacy_keyring(&ct, EncryptionContext::new(), wrong_keyring); + let result = decrypt(&dec_input).await; + let err = result.expect_err( + "decrypt must fail when default CMM cannot obtain decryption materials with wrong keyring", + ); + let ErrorKind::LegacyError(legacy) = &err.kind else { + panic!("expected LegacyError, got: {:?}", err.kind); + }; + let inner = format!("{legacy:?}"); + assert!( + inner.contains("Raw AES Keyring was unable to decrypt"), + "expected raw-AES decrypt failure, got: {inner}" + ); +} diff --git a/esdk/tests/test_post_cmm_validation.rs b/esdk/tests/test_post_cmm_validation.rs new file mode 100644 index 000000000..196ee6afd --- /dev/null +++ b/esdk/tests/test_post_cmm_validation.rs @@ -0,0 +1,123 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Tests for post-CMM commitment policy validation and decrypt identity KDF. + +mod fixtures; +mod test_helpers; + +use aws_esdk::*; +use aws_mpl_legacy::commitment::EsdkCommitmentPolicy; +use aws_mpl_legacy::suites::EsdkAlgorithmSuiteId; +use test_helpers::*; + +#[tokio::test(flavor = "multi_thread")] +async fn test_post_cmm_commitment_policy_round_trip() { + // Committing suite with matching policy succeeds; proves post-CMM validation passes. + let keyring = test_keyring().await; + let pt = b"test post-cmm commitment policy round trip"; + // Committing suite with RequireEncryptRequireDecrypt: post-CMM validation passes + let ct = encrypt_with_suite( + pt, + EsdkAlgorithmSuiteId::AlgAes256GcmHkdfSha512CommitKey, + EsdkCommitmentPolicy::RequireEncryptRequireDecrypt, + &keyring, + ) + .await; + let result = decrypt_with( + &ct, + EsdkCommitmentPolicy::RequireEncryptRequireDecrypt, + &keyring, + ) + .await; + assert_eq!( + result.plaintext, pt, + "committing suite with matching policy must decrypt successfully" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_encrypt_non_committing_with_require_policy_fails() { + // Non-committing suite + RequireEncryptRequireDecrypt must fail post-CMM. + let keyring = test_keyring().await; + let pt = b"test encrypt non-committing fails"; + // Non-committing suite with RequireEncryptRequireDecrypt: should fail + let mut enc_input = EncryptInput::with_legacy_keyring(pt, EncryptionContext::new(), keyring); + enc_input.algorithm_suite_id = Some(EsdkAlgorithmSuiteId::AlgAes256GcmIv12Tag16HkdfSha256); + enc_input.commitment_policy = EsdkCommitmentPolicy::RequireEncryptRequireDecrypt; + let result = encrypt(&enc_input).await; + //= spec/client-apis/encrypt.md#get-the-encryption-materials + //= type=test + //# If this [algorithm suite](../framework/algorithm-suites.md) is not supported by the [commitment policy](client.md#commitment-policy) + //# configured in the [client](client.md) encrypt MUST yield an error. + let err = result.expect_err( + "encrypt must fail when algorithm suite is not supported by commitment policy", + ); + let ErrorKind::LegacyError(legacy) = &err.kind else { + panic!("expected LegacyError, got: {:?}", err.kind); + }; + let inner = format!("{legacy:?}"); + assert!( + inner.contains("InvalidAlgorithmSuiteInfoOnEncrypt"), + "expected InvalidAlgorithmSuiteInfoOnEncrypt, got: {inner}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_decrypt_non_committing_with_require_policy_fails() { + // Non-committing suite + RequireEncryptRequireDecrypt must fail on decrypt. + let keyring = test_keyring().await; + let pt = b"test decrypt non-committing fails"; + // Encrypt with non-committing suite using ForbidEncryptAllowDecrypt + let ct = encrypt_with_suite( + pt, + EsdkAlgorithmSuiteId::AlgAes256GcmIv12Tag16HkdfSha256, + EsdkCommitmentPolicy::ForbidEncryptAllowDecrypt, + &keyring, + ) + .await; + // Decrypt with RequireEncryptRequireDecrypt: should fail because suite is non-committing + let mut dec_input = DecryptInput::with_legacy_keyring(&ct, EncryptionContext::new(), keyring); + dec_input.commitment_policy = EsdkCommitmentPolicy::RequireEncryptRequireDecrypt; + let result = decrypt(&dec_input).await; + //= spec/client-apis/decrypt.md#get-the-decryption-materials + //= type=test + //# If the algorithm suite is not supported by the [commitment policy](client.md#commitment-policy) + //# configured in the [client](client.md) decrypt MUST yield an error. + let err = result.expect_err( + "decrypt must fail when algorithm suite is not supported by commitment policy", + ); + let ErrorKind::LegacyError(legacy) = &err.kind else { + panic!("expected LegacyError, got: {:?}", err.kind); + }; + let inner = format!("{legacy:?}"); + assert!( + inner.contains("InvalidAlgorithmSuiteInfoOnDecrypt"), + "expected InvalidAlgorithmSuiteInfoOnDecrypt, got: {inner}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_identity_kdf_decrypt() { + // Round-trip with identity KDF suite proves the decrypt path handles non-HKDF derivation. + let keyring = test_keyring().await; + let pt = b"test identity kdf on decrypt path"; + // AlgAes256GcmIv12Tag16NoKdf uses identity KDF + let ct = encrypt_with_suite( + pt, + EsdkAlgorithmSuiteId::AlgAes256GcmIv12Tag16NoKdf, + EsdkCommitmentPolicy::ForbidEncryptAllowDecrypt, + &keyring, + ) + .await; + let result = decrypt_with( + &ct, + EsdkCommitmentPolicy::ForbidEncryptAllowDecrypt, + &keyring, + ) + .await; + assert_eq!( + result.plaintext, pt, + "round-trip with identity KDF suite succeeds" + ); +} diff --git a/esdk/tests/test_required_encryption_context.rs b/esdk/tests/test_required_encryption_context.rs new file mode 100644 index 000000000..0278772a0 --- /dev/null +++ b/esdk/tests/test_required_encryption_context.rs @@ -0,0 +1,236 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Tests for the Required Encryption Context CMM feature. +//! Covers spec/client-apis/encrypt.md#get-the-encryption-materials +//! and spec/client-apis/decrypt.md#get-the-decryption-materials, +//! focusing on the reproduced encryption context passed through the CMM. + +mod fixtures; +mod test_helpers; +use aws_esdk::*; +use aws_mpl_legacy::dafny::aws_cryptography_keyStore::client::Client as KeystoreClient; +use aws_mpl_legacy::dafny::aws_cryptography_keyStore::types::KmsConfiguration; +use aws_mpl_legacy::dafny::aws_cryptography_keyStore::types::key_store_config::KeyStoreConfig; +use aws_mpl_legacy::dafny::client::Client as MplClient; +use aws_mpl_legacy::dafny::types::keyring::KeyringRef; +use fixtures::*; + +// THIS IS A TESTING RESOURCE DO NOT USE IN A PRODUCTION ENVIRONMENT + +async fn get_rsa_keyring(mpl: &MplClient) -> KeyringRef { + let keys = generate_key_pair(2048).await; + let (namespace, name) = namespace_and_name(0); + mpl.create_raw_rsa_keyring() + .key_namespace(namespace) + .key_name(name) + .padding_scheme(aws_mpl_legacy::dafny::types::PaddingScheme::OaepSha1Mgf1) + .public_key(keys.public_key.unwrap().pem.unwrap().as_ref()) + .private_key(keys.private_key.unwrap().pem.unwrap().as_ref()) + .send() + .await + .unwrap() +} + +async fn get_aes_keyring(mpl: &MplClient) -> KeyringRef { + let (namespace, name) = namespace_and_name(0); + mpl.create_raw_aes_keyring() + .key_namespace(namespace) + .key_name(name) + .wrapping_key(aws_smithy_types::Blob::new([0; 32])) + .wrapping_alg(aws_mpl_legacy::dafny::types::AesWrappingAlg::AlgAes256GcmIv12Tag16) + .send() + .await + .unwrap() +} + +async fn get_kms_keyring(mpl: &MplClient) -> KeyringRef { + let client_supplier = mpl.create_default_client_supplier().send().await.unwrap(); + let kms_client = client_supplier + .get_client() + .region("us-west-2") + .send() + .await + .unwrap(); + + mpl.create_aws_kms_keyring() + .kms_key_id(KEY_ARN) + .kms_client(kms_client) + .send() + .await + .unwrap() +} + +async fn get_hierarchical_keyring(mpl: &MplClient) -> KeyringRef { + let sdk_config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + let kms_client = aws_sdk_kms::Client::new(&sdk_config); + let ddb_client = aws_sdk_dynamodb::Client::new(&sdk_config); + let kms_config = KmsConfiguration::KmsKeyArn(HIERARCHY_KEY_ARN.to_string()); + + let key_store_config = KeyStoreConfig::builder() + .kms_client(kms_client) + .ddb_client(ddb_client) + .ddb_table_name(BRANCH_KEY_STORE_NAME) + .logical_key_store_name(LOGICAL_KEY_STORE_NAME) + .kms_configuration(kms_config) + .build() + .unwrap(); + + let key_store = KeystoreClient::from_conf(key_store_config).unwrap(); + + mpl.create_aws_kms_hierarchical_keyring() + .key_store(key_store) + .branch_key_id(BRANCH_KEY_ID) + .ttl_seconds(600000) + .send() + .await + .unwrap() +} + +//= spec/client-apis/decrypt.md#get-the-decryption-materials +//= type=test +//# - Reproduced Encryption Context: This MUST be the [input](#input) encryption context. +#[tokio::test(flavor = "multi_thread")] +async fn test_repr_encryption_context_with_same_ec_happy_case() { + let asdf = "asdf".as_bytes(); + let mpl = mpl(); + + // get keyrings + let rsa_keyring = get_rsa_keyring(&mpl).await; + let kms_keyring = get_kms_keyring(&mpl).await; + let aes_keyring = get_aes_keyring(&mpl).await; + let h_keyring = get_hierarchical_keyring(&mpl).await; + + let multi_keyring = mpl + .create_multi_keyring() + .generator(aes_keyring.clone()) + .child_keyrings([rsa_keyring.clone(), kms_keyring.clone(), h_keyring.clone()]) + .send() + .await + .unwrap(); + + // HAPPY CASE 1 + // Test supply same encryption context on encrypt and decrypt NO filtering + let encryption_context = small_encryption_context(SmallEncryptionContextVariation::AB); + + let encrypt_input = + EncryptInput::with_legacy_keyring(asdf, encryption_context.clone(), multi_keyring.clone()); + let encrypt_output = encrypt(&encrypt_input).await.unwrap(); + let esdk_ciphertext = encrypt_output.ciphertext; + + // Test RSA + let mut decrypt_input = DecryptInput::with_legacy_keyring( + &esdk_ciphertext, + encryption_context, + rsa_keyring.clone(), + ); + let decrypt_output = decrypt(&decrypt_input).await.unwrap(); + assert_eq!(decrypt_output.plaintext, asdf); + + // Test KMS + decrypt_input.source = Some(MaterialSource::LegacyKeyring(kms_keyring.clone())); + let decrypt_output = decrypt(&decrypt_input).await.unwrap(); + assert_eq!(decrypt_output.plaintext, asdf); + + // Test AES + decrypt_input.source = Some(MaterialSource::LegacyKeyring(aes_keyring.clone())); + let decrypt_output = decrypt(&decrypt_input).await.unwrap(); + assert_eq!(decrypt_output.plaintext, asdf); + + // Test Hierarchy Keyring + decrypt_input.source = Some(MaterialSource::LegacyKeyring(h_keyring.clone())); + let decrypt_output = decrypt(&decrypt_input).await.unwrap(); + assert_eq!(decrypt_output.plaintext, asdf); +} + +//= spec/client-apis/encrypt.md#get-the-encryption-materials +//= type=test +//# The CMM used MUST be the input CMM, if supplied. +#[tokio::test(flavor = "multi_thread")] +async fn test_remove_on_encrypt_and_supply_on_decrypt_happy_case() { + let asdf = "asdf".as_bytes(); + let mpl = mpl(); + + // get keyrings + let rsa_keyring = get_rsa_keyring(&mpl).await; + let kms_keyring = get_kms_keyring(&mpl).await; + let aes_keyring = get_aes_keyring(&mpl).await; + let h_keyring = get_hierarchical_keyring(&mpl).await; + + let multi_keyring = mpl + .create_multi_keyring() + .generator(aes_keyring.clone()) + .child_keyrings([rsa_keyring.clone(), kms_keyring.clone(), h_keyring.clone()]) + .send() + .await + .unwrap(); + + // Happy Test Case 2 + // On Encrypt we will only write one encryption context key value to the header + // we will then supply only what we didn't write wth no required ec cmm, + // This test case is checking that the default cmm is doing the correct filtering by using + let encryption_context = small_encryption_context(SmallEncryptionContextVariation::AB); + let reproduced_encryption_context = + small_encryption_context(SmallEncryptionContextVariation::A); + // These keys mean that we will not write these on the message but are required for message authentication on decrypt. + let required_encryption_context_keys = + small_encryption_context_keys(SmallEncryptionContextVariation::A); + + // TEST RSA + let default_cmm = mpl + .create_default_cryptographic_materials_manager() + .keyring(multi_keyring.clone()) + .send() + .await + .unwrap(); + + // Create Required EC CMM with the required EC Keys we want + let req_cmm = mpl + .create_required_encryption_context_cmm() + .underlying_cmm(default_cmm) + // At the moment reqCMM can only be created with a CMM, you cannot + // create one by only passing in a keyring. + .required_encryption_context_keys(required_encryption_context_keys) + .send() + .await + .unwrap(); + + let encrypt_input = EncryptInput::with_legacy_cmm(asdf, encryption_context, req_cmm); + let encrypt_output = encrypt(&encrypt_input).await.unwrap(); + let esdk_ciphertext = encrypt_output.ciphertext; + + //= spec/client-apis/decrypt.md#get-the-decryption-materials + //= type=test + //# - Reproduced Encryption Context: This MUST be the [input](#input) encryption context. + let mut decrypt_input = DecryptInput::with_legacy_keyring( + &esdk_ciphertext, + reproduced_encryption_context, + rsa_keyring.clone(), + ); + let decrypt_output = decrypt(&decrypt_input).await.unwrap(); + //= spec/client-apis/encrypt.md#authentication-tag + //= type=test + //= reason=Decrypt with reproduced EC succeeds; proves encrypt AAD included filtered EC + //# The encryption context to only authenticate MUST be the [encryption context](../framework/structures.md#encryption-context) + //# in the [encryption materials](../framework/structures.md#encryption-materials) + //# filtered to only contain key value pairs listed in + //# the [encryption material's](../framework/structures.md#encryption-materials) + //# [required encryption context keys](../framework/structures.md#required-encryption-context-keys) + //# serialized according to the [encryption context serialization specification](../framework/structures.md#serialization). + assert_eq!(decrypt_output.plaintext, asdf); + + // Test KMS + decrypt_input.source = Some(MaterialSource::LegacyKeyring(kms_keyring.clone())); + let decrypt_output = decrypt(&decrypt_input).await.unwrap(); + assert_eq!(decrypt_output.plaintext, asdf); + + // Test AES + decrypt_input.source = Some(MaterialSource::LegacyKeyring(aes_keyring.clone())); + let decrypt_output = decrypt(&decrypt_input).await.unwrap(); + assert_eq!(decrypt_output.plaintext, asdf); + + // Test Hierarchy Keyring + decrypt_input.source = Some(MaterialSource::LegacyKeyring(h_keyring.clone())); + let decrypt_output = decrypt(&decrypt_input).await.unwrap(); + assert_eq!(decrypt_output.plaintext, asdf); +}