-
Notifications
You must be signed in to change notification settings - Fork 598
fix(aztec-nr): account for AES PKCS#7 padding in message plaintext length #20840
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
31738d4
f58f9ac
910d466
9b7cf94
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +11,7 @@ use crate::{ | |
| messages::{ | ||
| encoding::{ | ||
| EPH_PK_SIGN_BYTE_SIZE_IN_BYTES, EPH_PK_X_SIZE_IN_FIELDS, HEADER_CIPHERTEXT_SIZE_IN_BYTES, | ||
| MESSAGE_CIPHERTEXT_LEN, MESSAGE_PLAINTEXT_LEN, | ||
| MESSAGE_CIPHERTEXT_LEN, MESSAGE_PLAINTEXT_LEN, MESSAGE_PLAINTEXT_SIZE_IN_BYTES, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a bit weird that we take the constant from messages, since the constant itself is derived from AES. But this will make sense later on. It does look a bit odd now though.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree, we should refactor it when we start making more changes to this |
||
| }, | ||
| encryption::message_encryption::MessageEncryption, | ||
| logs::arithmetic_generics_utils::{ | ||
|
|
@@ -150,17 +150,101 @@ pub fn derive_aes_symmetric_key_and_iv_from_ecdh_shared_secret_using_poseidon2_u | |
| pub struct AES128 {} | ||
|
|
||
| impl MessageEncryption for AES128 { | ||
|
|
||
| /// AES128-CBC encryption for Aztec protocol messages. | ||
| /// | ||
| /// ## Overview | ||
| /// | ||
|
Comment on lines
+154
to
+157
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. y u no 120 char
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed, reflowed to 120 chars. |
||
| /// The plaintext is an array of up to `MESSAGE_PLAINTEXT_LEN` (12) fields. The output is always exactly | ||
| /// `MESSAGE_CIPHERTEXT_LEN` (15) fields, regardless of plaintext size. Unused trailing fields are filled with | ||
| /// random data so that all encrypted messages are indistinguishable by size. | ||
| /// | ||
| /// ## PKCS#7 Padding | ||
| /// | ||
| /// AES operates on 16-byte blocks, so the plaintext must be padded to a multiple of 16. PKCS#7 padding always | ||
| /// adds at least 1 byte (so the receiver can always detect and strip it), which means: | ||
| /// - 1 B plaintext -> 15 B padding -> 16 B total | ||
| /// - 15 B plaintext -> 1 B padding -> 16 B total | ||
| /// - 16 B plaintext -> 16 B padding -> 32 B total (full extra block) | ||
| /// | ||
| /// In general: if the plaintext is already a multiple of 16, a full 16-byte padding block is appended. | ||
| /// | ||
| /// ## Encryption Steps | ||
| /// | ||
| /// **1. Body encryption.** The plaintext fields are serialized to bytes (32 bytes per field) and AES-128-CBC | ||
| /// encrypted. Since 32 is a multiple of 16, PKCS#7 always adds a full 16-byte padding block (see above): | ||
| /// | ||
| /// ```text | ||
| /// +---------------------------------------------+ | ||
| /// | body ct | | ||
| /// | PlaintextLen*32 + 16 B | | ||
| /// +-------------------------------+--------------+ | ||
| /// | encrypted plaintext fields | PKCS#7 (16B) | | ||
| /// | (serialized at 32 B each) | | | ||
| /// +-------------------------------+--------------+ | ||
| /// ``` | ||
| /// | ||
| /// **2. Header encryption.** The byte length of `body_ct` is stored as a 2-byte big-endian integer. This 2-byte | ||
| /// header plaintext is then AES-encrypted; PKCS#7 pads the remaining 14 bytes to fill one 16-byte AES block, | ||
| /// producing a 16-byte header ciphertext: | ||
| /// | ||
| /// ```text | ||
| /// +---------------------------+ | ||
| /// | header ct | | ||
| /// | 16 B | | ||
| /// +--------+------------------+ | ||
| /// | body ct| PKCS#7 (14B) | | ||
| /// | length | | | ||
| /// | (2 B) | | | ||
| /// +--------+------------------+ | ||
| /// ``` | ||
| /// | ||
| /// ## Wire Format | ||
| /// | ||
| /// Messages are transmitted as fields, not bytes. A field is ~254 bits and can safely store 31 whole bytes, so | ||
| /// we need to pack our byte data into 31-byte chunks. This packing drives the wire format. | ||
| /// | ||
| /// **Step 1 -- Assemble bytes.** The ciphertexts are laid out in a byte array, padded with random bytes to a | ||
| /// multiple of 31 so it divides evenly into fields: | ||
| /// | ||
| /// ```text | ||
| /// +---------+------------+-------------------------+---------+ | ||
| /// | pk sign | header ct | body ct | byte pad| | ||
| /// | 1 B | 16 B | PlaintextLen*32 + 16 B | (random)| | ||
| /// +---------+------------+-------------------------+---------+ | ||
| /// |<----------- padded to a multiple of 31 B ------------->| | ||
| /// ``` | ||
| /// | ||
| /// **Step 2 -- Pack into fields.** The byte array is split into 31-byte chunks, each stored in one field. The | ||
| /// ephemeral public key x-coordinate is prepended as its own field. Any remaining fields (up to 15 total) are | ||
| /// filled with random data so that all messages are the same size: | ||
| /// | ||
| /// ```text | ||
| /// +----------+-------------------------+-------------------+ | ||
| /// | eph_pk.x | message-byte fields | random field pad | | ||
| /// | | (packed 31 B per field) | (fills to 15) | | ||
| /// +----------+-------------------------+-------------------+ | ||
| /// |<---------- MESSAGE_CIPHERTEXT_LEN = 15 fields ------->| | ||
| /// ``` | ||
| /// | ||
| /// ## Key Derivation | ||
| /// | ||
| /// Two (key, IV) pairs are derived from the ECDH shared secret via Poseidon2 hashing with different domain | ||
| /// separators: one pair for the body ciphertext and one for the header ciphertext. | ||
| fn encrypt<let PlaintextLen: u32>( | ||
| plaintext: [Field; PlaintextLen], | ||
| recipient: AztecAddress, | ||
| ) -> [Field; MESSAGE_CIPHERTEXT_LEN] { | ||
| std::static_assert( | ||
| PlaintextLen <= MESSAGE_PLAINTEXT_LEN, | ||
| "Plaintext length exceeds MESSAGE_PLAINTEXT_LEN", | ||
| ); | ||
|
|
||
| // AES 128 operates on bytes, not fields, so we need to convert the fields to bytes. (This process is then | ||
| // reversed when processing the message in `process_message_ciphertext`) | ||
| let plaintext_bytes = fields_to_bytes(plaintext); | ||
|
|
||
| // ***************************************************************************** Compute the shared secret | ||
| // ***************************************************************************** | ||
|
|
||
| // Derive ECDH shared secret with recipient using a fresh ephemeral keypair. | ||
| let (eph_sk, eph_pk) = generate_ephemeral_key_pair(); | ||
|
|
||
| let eph_pk_sign_byte: u8 = get_sign_of_point(eph_pk) as u8; | ||
|
|
@@ -189,15 +273,7 @@ impl MessageEncryption for AES128 { | |
| ); | ||
| // TODO: also use this shared secret for deriving note randomness. | ||
|
|
||
| // ***************************************************************************** Convert the plaintext into | ||
| // whatever format the encryption function expects | ||
| // ***************************************************************************** | ||
|
|
||
| // Already done for this strategy: AES expects bytes. | ||
|
|
||
| // ***************************************************************************** Encrypt the plaintext | ||
| // ***************************************************************************** | ||
|
|
||
| // AES128-CBC encrypt the plaintext bytes. | ||
| // It is safe to call the `unsafe` function here, because we know the `shared_secret` was derived using an | ||
| // AztecAddress (the recipient). See the block comment at the start of this unsafe target function for more | ||
| // info. | ||
|
|
@@ -209,22 +285,15 @@ impl MessageEncryption for AES128 { | |
|
|
||
| let ciphertext_bytes = aes128_encrypt(plaintext_bytes, body_iv, body_sym_key); | ||
|
|
||
| // |full_pt| = |pt_length| + |pt| | ||
| // |pt_aes_padding| = 16 - (|full_pt| % 16) | ||
| // or... since a % b is the same as a - b * (a // b) (integer division), so: | ||
| // |pt_aes_padding| = 16 - (|full_pt| - 16 * (|full_pt| // 16)) | ||
| // |ct| = |full_pt| + |pt_aes_padding| | ||
| // = |full_pt| + 16 - (|full_pt| - 16 * (|full_pt| // 16)) = 16 + 16 * (|full_pt| // 16) = 16 * (1 + | ||
| // |full_pt| // 16) | ||
| // Each plaintext field is 32 bytes (a multiple of the 16-byte AES block | ||
| // size), so PKCS#7 always appends a full 16-byte padding block: | ||
| // |ciphertext| = PlaintextLen*32 + 16 = 16 * (1 + PlaintextLen*32 / 16) | ||
| std::static_assert( | ||
| ciphertext_bytes.len() == 16 * (1 + (PlaintextLen * 32) / 16), | ||
| "unexpected ciphertext length", | ||
| ); | ||
|
|
||
| // ***************************************************************************** Compute the header ciphertext | ||
| // ***************************************************************************** | ||
|
|
||
| // Header contains only the length of the ciphertext stored in 2 bytes. | ||
| // Encrypt a 2-byte header containing the body ciphertext length. | ||
| let mut header_plaintext: [u8; 2] = [0 as u8; 2]; | ||
| let ciphertext_bytes_length = ciphertext_bytes.len(); | ||
| header_plaintext[0] = (ciphertext_bytes_length >> 8) as u8; | ||
|
|
@@ -233,16 +302,14 @@ impl MessageEncryption for AES128 { | |
| // Note: the aes128_encrypt builtin fn automatically appends bytes to the input, according to pkcs#7; hence why | ||
| // the output `header_ciphertext_bytes` is 16 bytes larger than the input in this case. | ||
| let header_ciphertext_bytes = aes128_encrypt(header_plaintext, header_iv, header_sym_key); | ||
| // I recall that converting a slice to an array incurs constraints, so I'll check the length this way instead: | ||
| // Verify expected header ciphertext size at compile time. | ||
| std::static_assert( | ||
| header_ciphertext_bytes.len() == HEADER_CIPHERTEXT_SIZE_IN_BYTES, | ||
| "unexpected ciphertext header length", | ||
| ); | ||
|
|
||
| // ***************************************************************************** Prepend / append more bytes of | ||
| // data to the ciphertext, before converting back to fields. | ||
| // ***************************************************************************** | ||
|
|
||
| // Assemble the message byte array: | ||
| // [eph_pk_sign (1B)] [header_ct (16B)] [body_ct] [padding to mult of 31] | ||
| let mut message_bytes_padding_to_mult_31 = | ||
| get_arr_of_size__message_bytes_padding__from_PT::<PlaintextLen * 32>(); | ||
| // Safety: this randomness won't be constrained to be random. It's in the interest of the executor of this fn | ||
|
|
@@ -285,17 +352,12 @@ impl MessageEncryption for AES128 { | |
| ); | ||
| assert(offset == message_bytes.len(), "unexpected encrypted message length"); | ||
|
|
||
| // ***************************************************************************** Convert bytes back to fields | ||
| // ***************************************************************************** | ||
|
|
||
| // Pack message bytes into fields (31 bytes per field) and prepend eph_pk.x. | ||
| // TODO(#12749): As Mike pointed out, we need to make messages produced by different encryption schemes | ||
| // indistinguishable from each other and for this reason the output here and in the last for-loop of this | ||
| // function should cover a full field. | ||
| let message_bytes_as_fields = bytes_to_fields(message_bytes); | ||
|
|
||
| // ***************************************************************************** Prepend / append fields, to | ||
| // create the final message ***************************************************************************** | ||
|
|
||
| let mut ciphertext: [Field; MESSAGE_CIPHERTEXT_LEN] = [0; MESSAGE_CIPHERTEXT_LEN]; | ||
|
|
||
| ciphertext[0] = eph_pk.x; | ||
|
|
@@ -368,16 +430,16 @@ impl MessageEncryption for AES128 { | |
|
|
||
| // Extract and decrypt main ciphertext | ||
| let ciphertext_start = header_start + HEADER_CIPHERTEXT_SIZE_IN_BYTES; | ||
| let ciphertext_with_padding: [u8; (MESSAGE_CIPHERTEXT_LEN - EPH_PK_X_SIZE_IN_FIELDS) * 31 - HEADER_CIPHERTEXT_SIZE_IN_BYTES - EPH_PK_SIGN_BYTE_SIZE_IN_BYTES] = | ||
| let ciphertext_with_padding: [u8; MESSAGE_PLAINTEXT_SIZE_IN_BYTES] = | ||
| array::subarray(ciphertext_without_eph_pk_x.storage(), ciphertext_start); | ||
| let ciphertext: BoundedVec<u8, (MESSAGE_CIPHERTEXT_LEN - EPH_PK_X_SIZE_IN_FIELDS) * 31 - HEADER_CIPHERTEXT_SIZE_IN_BYTES - EPH_PK_SIGN_BYTE_SIZE_IN_BYTES> = | ||
| let ciphertext: BoundedVec<u8, MESSAGE_PLAINTEXT_SIZE_IN_BYTES> = | ||
| BoundedVec::from_parts(ciphertext_with_padding, ciphertext_length); | ||
|
|
||
| // Decrypt main ciphertext and return it | ||
| let plaintext_bytes = aes128_decrypt_oracle(ciphertext, body_iv, body_sym_key); | ||
|
|
||
| // Each field of the original note message was serialized to 32 bytes so we convert the bytes back to | ||
| // fields. | ||
| // Each field of the original message was serialized to 32 bytes so we convert | ||
| // the bytes back to fields. | ||
| fields_from_bytes(plaintext_bytes) | ||
| }) | ||
| } | ||
|
|
@@ -489,6 +551,48 @@ mod test { | |
| let _ = AES128::encrypt([1, 2, 3, 4], invalid_address); | ||
| } | ||
|
|
||
| // Documents the PKCS#7 padding behavior that `encrypt` relies on (see its static_assert). | ||
| #[test] | ||
| fn pkcs7_padding_always_adds_at_least_one_byte() { | ||
| let key = [0 as u8; 16]; | ||
| let iv = [0 as u8; 16]; | ||
|
|
||
| // 1 byte input + 15 bytes padding = 16 bytes | ||
| assert_eq(std::aes128::aes128_encrypt([0; 1], iv, key).len(), 16); | ||
|
|
||
| // 15 bytes input + 1 byte padding = 16 bytes | ||
| assert_eq(std::aes128::aes128_encrypt([0; 15], iv, key).len(), 16); | ||
|
|
||
| // 16 bytes input (block-aligned) + full 16-byte padding block = 32 bytes | ||
| assert_eq(std::aes128::aes128_encrypt([0; 16], iv, key).len(), 32); | ||
| } | ||
|
|
||
| #[test] | ||
| unconstrained fn encrypt_decrypt_max_size_plaintext() { | ||
| let mut env = TestEnvironment::new(); | ||
| let recipient = env.create_light_account(); | ||
|
|
||
| env.private_context(|_| { | ||
| let mut plaintext = [0; MESSAGE_PLAINTEXT_LEN]; | ||
| for i in 0..MESSAGE_PLAINTEXT_LEN { | ||
| plaintext[i] = i as Field; | ||
| } | ||
| let ciphertext = AES128::encrypt(plaintext, recipient); | ||
|
|
||
| assert_eq( | ||
| AES128::decrypt(BoundedVec::from_array(ciphertext), recipient).unwrap(), | ||
| BoundedVec::from_array(plaintext), | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| #[test(should_fail_with = "Plaintext length exceeds MESSAGE_PLAINTEXT_LEN")] | ||
| unconstrained fn encrypt_oversized_plaintext() { | ||
| let address = AztecAddress { inner: 3 }; | ||
| let plaintext: [Field; MESSAGE_PLAINTEXT_LEN + 1] = [0; MESSAGE_PLAINTEXT_LEN + 1]; | ||
| let _ = AES128::encrypt(plaintext, address); | ||
| } | ||
|
|
||
| #[test] | ||
| unconstrained fn random_address_point_produces_valid_points() { | ||
| // About half of random addresses are invalid, so testing just a couple gives us high confidence that | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am very surprised this is caught by a test. Static asserts trigger compilation errors, not runtime errors, and I thought
#[test]would not catch these - I expectednargo testto fail instead. Can you check with the Noir team that this is correct?