From 31738d45fa1042f9c6fab596d65f349556306b07 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Tue, 24 Feb 2026 22:19:14 -0300 Subject: [PATCH 1/4] fix(aztec-nr): account for AES PKCS#7 padding in message plaintext length --- .../aztec-nr/aztec/src/messages/encoding.nr | 26 +++++++++--- .../aztec/src/messages/encryption/aes128.nr | 40 +++++++++++++++++-- .../aztec-nr/aztec/src/messages/logs/note.nr | 36 ++++++++++++++++- .../processing/event_validation_request.nr | 2 +- .../processing/note_validation_request.nr | 1 - .../event_validation_request.test.ts | 1 - .../noir-structs/event_validation_request.ts | 2 +- .../note_validation_request.test.ts | 8 ++-- .../noir-structs/note_validation_request.ts | 2 +- yarn-project/txe/src/rpc_translator.ts | 2 +- 10 files changed, 100 insertions(+), 20 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/messages/encoding.nr b/noir-projects/aztec-nr/aztec/src/messages/encoding.nr index d810aac2abf2..2b1fada3e64f 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/encoding.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/encoding.nr @@ -9,21 +9,25 @@ use crate::utils::array; // fields, so MESSAGE_CIPHERTEXT_LEN is the size of the message in fields. pub global MESSAGE_CIPHERTEXT_LEN: u32 = PRIVATE_LOG_CIPHERTEXT_LEN; -// TODO(#12750): The global variables below should not be here as they are AES128 specific. ciphertext_length (2) + 14 -// bytes pkcs#7 AES padding. +// TODO(#12750): The global variables below should not be here as they are AES128 specific. +// The header plaintext is 2 bytes (ciphertext length), padded to the 16-byte AES block size by PKCS#7. pub(crate) global HEADER_CIPHERTEXT_SIZE_IN_BYTES: u32 = 16; +// AES PKCS#7 always adds at least one byte of padding. Since each plaintext field is 32 bytes (a multiple of the +// 16-byte AES block size), a full 16-byte padding block is always appended. +pub(crate) global AES128_PKCS7_EXPANSION_IN_BYTES: u32 = 16; pub global EPH_PK_X_SIZE_IN_FIELDS: u32 = 1; pub global EPH_PK_SIGN_BYTE_SIZE_IN_BYTES: u32 = 1; -// (17 - 1) * 31 - 16 - 1 = 479 Note: We multiply by 31 because ciphertext bytes are stored in fields using +// (15 - 1) * 31 - 16 - 1 = 417. We multiply by 31 because ciphertext bytes are stored in fields using // bytes_to_fields, which packs 31 bytes per field (since a Field is ~254 bits and can safely store 31 whole bytes). global MESSAGE_PLAINTEXT_SIZE_IN_BYTES: u32 = (MESSAGE_CIPHERTEXT_LEN - EPH_PK_X_SIZE_IN_FIELDS) * 31 - HEADER_CIPHERTEXT_SIZE_IN_BYTES - EPH_PK_SIGN_BYTE_SIZE_IN_BYTES; // The plaintext bytes represent Field values that were originally serialized using fields_to_bytes, which converts -// each Field to 32 bytes. To convert the plaintext bytes back to fields, we divide by 32. 479 / 32 = 14 -pub global MESSAGE_PLAINTEXT_LEN: u32 = MESSAGE_PLAINTEXT_SIZE_IN_BYTES / 32; +// each Field to 32 bytes. To convert the plaintext bytes back to fields, we divide by 32. We must also account for +// AES PKCS#7 padding expansion that is always appended. (417 - 16) / 32 = 12 +pub global MESSAGE_PLAINTEXT_LEN: u32 = (MESSAGE_PLAINTEXT_SIZE_IN_BYTES - AES128_PKCS7_EXPANSION_IN_BYTES) / 32; pub global MESSAGE_EXPANDED_METADATA_LEN: u32 = 1; @@ -244,4 +248,16 @@ mod tests { assert_eq(original_msg_type, unpacked_msg_type); assert_eq(original_msg_metadata, unpacked_msg_metadata); } + + #[test] + fn encode_max_size_message() { + let msg_content = [0; MAX_MESSAGE_CONTENT_LEN]; + let _ = encode_message(0, 0, msg_content); + } + + #[test(should_fail_with = "Invalid message content: it must have a length of at most MAX_MESSAGE_CONTENT_LEN")] + fn encode_oversized_message_fails() { + let msg_content = [0; MAX_MESSAGE_CONTENT_LEN + 1]; + let _ = encode_message(0, 0, msg_content); + } } diff --git a/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr b/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr index 97f32377be8f..c5fbde2dd3ed 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr @@ -376,9 +376,13 @@ impl MessageEncryption for AES128 { // 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. - fields_from_bytes(plaintext_bytes) + // Each field of the original message was serialized to 32 bytes so we convert back to fields. + // `fields_from_bytes` returns BoundedVec which + // may be larger than MESSAGE_PLAINTEXT_LEN, because MESSAGE_PLAINTEXT_SIZE_IN_BYTES is the + // raw byte capacity *before* subtracting AES PKCS#7 padding. After decryption the padding is + // stripped, so the actual content always fits in MESSAGE_PLAINTEXT_LEN fields. We shrink the + // capacity with subbvec to match the return type. + array::subbvec(fields_from_bytes(plaintext_bytes), 0) }) } } @@ -407,6 +411,7 @@ mod test { keys::ecdh_shared_secret::derive_ecdh_shared_secret, messages::{encoding::MESSAGE_PLAINTEXT_LEN, encryption::message_encryption::MessageEncryption}, test::helpers::test_environment::TestEnvironment, + utils::conversion::fields_to_bytes::fields_to_bytes, }; use crate::protocol::{address::AztecAddress, traits::FromField}; use super::{AES128, random_address_point}; @@ -489,6 +494,35 @@ mod test { let _ = AES128::encrypt([1, 2, 3, 4], invalid_address); } + #[test] + fn aes128_pkcs7_adds_full_block_for_field_aligned_input() { + // `fields_to_bytes` serializes each field to exactly 32 bytes, which is a multiple of + // the 16-byte AES block size. PKCS#7 therefore always appends a full 16-byte padding + // block. + let key = [0 as u8; 16]; + let iv = [0 as u8; 16]; + + let one_field_bytes = fields_to_bytes([0; 1]); + assert_eq(std::aes128::aes128_encrypt(one_field_bytes, iv, key).len(), 32 + 16); + + let two_field_bytes = fields_to_bytes([0; 2]); + assert_eq(std::aes128::aes128_encrypt(two_field_bytes, iv, key).len(), 64 + 16); + } + + #[test] + unconstrained fn encrypt_max_size_plaintext() { + let address = AztecAddress { inner: 3 }; + let plaintext: [Field; MESSAGE_PLAINTEXT_LEN] = [0; MESSAGE_PLAINTEXT_LEN]; + let _ = AES128::encrypt(plaintext, address); + } + + #[test(should_fail)] + 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 diff --git a/noir-projects/aztec-nr/aztec/src/messages/logs/note.nr b/noir-projects/aztec-nr/aztec/src/messages/logs/note.nr index 6b7380b2a197..89a81940aa38 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/logs/note.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/logs/note.nr @@ -89,7 +89,7 @@ mod test { use crate::{ messages::{ encoding::decode_message, - logs::note::{decode_private_note_message, encode_private_note_message}, + logs::note::{decode_private_note_message, encode_private_note_message, MAX_NOTE_PACKED_LEN}, msg_type::PRIVATE_NOTE_MSG_TYPE_ID, }, note::note_interface::NoteType, @@ -121,4 +121,38 @@ mod test { assert_eq(randomness, RANDOMNESS); assert_eq(packed_note, BoundedVec::from_array(note.pack())); } + + #[derive(Packable)] + struct MaxSizeNote { + data: [Field; MAX_NOTE_PACKED_LEN], + } + + impl NoteType for MaxSizeNote { + fn get_id() -> Field { + 0 + } + } + + #[test] + fn encode_max_size_note() { + let note = MaxSizeNote { data: [0; MAX_NOTE_PACKED_LEN] }; + let _ = encode_private_note_message(note, OWNER, STORAGE_SLOT, RANDOMNESS); + } + + #[derive(Packable)] + struct OversizedNote { + data: [Field; MAX_NOTE_PACKED_LEN + 1], + } + + impl NoteType for OversizedNote { + fn get_id() -> Field { + 0 + } + } + + #[test(should_fail_with = "Invalid message content: it must have a length of at most MAX_MESSAGE_CONTENT_LEN")] + fn encode_oversized_note_fails() { + let note = OversizedNote { data: [0; MAX_NOTE_PACKED_LEN + 1] }; + let _ = encode_private_note_message(note, OWNER, STORAGE_SLOT, RANDOMNESS); + } } diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/event_validation_request.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/event_validation_request.nr index f7ed86b32672..8e757e1fbf1c 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/event_validation_request.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/event_validation_request.nr @@ -42,7 +42,7 @@ mod test { 3, // randomness 4, // serialized_event[0] 5, // serialized_event[1] - 0, 0, 0, 0, 0, 0, 0, 0, 0, // serialized_event padding + 0, 0, 0, 0, 0, 0, 0, 0, // serialized_event padding 2, // bounded_vec_len 6, // event_commitment 7, // tx_hash diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/note_validation_request.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/note_validation_request.nr index 060fb61eb3a6..00d0e1ef4738 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/note_validation_request.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/note_validation_request.nr @@ -55,7 +55,6 @@ mod test { 0x0000000000000000000000000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000002, 0x0000000000000000000000000000000000000000000000000000000000000006, 0x0000000000000000000000000000000000000000000000000000000000000007, diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts index 44c74418d3fb..63816730790f 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts @@ -20,7 +20,6 @@ describe('EventValidationRequest', () => { 0, 0, 0, - 0, 0, // serialized_event padding end 2, // bounded_vec_len 6, // event_commitment diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts index 6749a66299f0..8a33dd551923 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts @@ -5,7 +5,7 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { TxHash } from '@aztec/stdlib/tx'; // TODO(#14617): should we compute this from constants? This value is aztec-nr specific. -const MAX_EVENT_SERIALIZED_LEN = 11; +const MAX_EVENT_SERIALIZED_LEN = 10; /** * Intermediate struct used to perform batch event validation by PXE. The `utilityValidateAndStoreEnqueuedNotesAndEvents` oracle diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts index e798eb3c190c..4ac64de1d016 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts @@ -19,8 +19,7 @@ describe('NoteValidationRequest', () => { '0x0000000000000000000000000000000000000000000000000000000000000000', '0x0000000000000000000000000000000000000000000000000000000000000000', '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', // content end (MAX_NOTE_PACKED_LEN = 10) + '0x0000000000000000000000000000000000000000000000000000000000000000', // content end (MAX_NOTE_PACKED_LEN = 8) '0x0000000000000000000000000000000000000000000000000000000000000002', // content length '0x0000000000000000000000000000000000000000000000000000000000000006', // note hash '0x0000000000000000000000000000000000000000000000000000000000000007', // nullifier @@ -57,9 +56,8 @@ describe('NoteValidationRequest', () => { '0x0000000000000000000000000000000000000000000000000000000000000000', '0x0000000000000000000000000000000000000000000000000000000000000000', '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', // content end (MAX_NOTE_PACKED_LEN = 10) - '0x0000000000000000000000000000000000000000000000000000000000000000', // extra item, this is a malformed serialization + '0x0000000000000000000000000000000000000000000000000000000000000000', // content end (MAX_NOTE_PACKED_LEN = 8) + '0x0000000000000000000000000000000000000000000000000000000000000000', // extra field beyond MAX_NOTE_PACKED_LEN, this is a malformed serialization '0x0000000000000000000000000000000000000000000000000000000000000002', // content length '0x0000000000000000000000000000000000000000000000000000000000000006', // note hash '0x0000000000000000000000000000000000000000000000000000000000000007', // nullifier diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts index 8b793434ac40..02ebba99e96e 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts @@ -4,7 +4,7 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { TxHash } from '@aztec/stdlib/tx'; // TODO(#14617): should we compute this from constants? This value is aztec-nr specific. -export const MAX_NOTE_PACKED_LEN = 9; +export const MAX_NOTE_PACKED_LEN = 8; /** * Intermediate struct used to perform batch note validation by PXE. The `utilityValidateAndStoreEnqueuedNotesAndEvents` oracle diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index 95995654f675..55deeb05b91e 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -30,7 +30,7 @@ import { toSingle, } from './util/encoding.js'; -const MAX_EVENT_LEN = 12; // This is MAX_MESSAGE_CONTENT_LEN - PRIVATE_EVENT_RESERVED_FIELDS +const MAX_EVENT_LEN = 10; // This is MAX_MESSAGE_CONTENT_LEN - PRIVATE_EVENT_MSG_PLAINTEXT_RESERVED_FIELDS_LEN const MAX_PRIVATE_EVENTS_PER_TXE_QUERY = 5; export class UnavailableOracleError extends Error { From f58f9ac24d9d14ab731daa4690e02fbd292d8c21 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Thu, 26 Feb 2026 12:23:43 -0300 Subject: [PATCH 2/4] fix(aztec-nr): use MESSAGE_PLAINTEXT_SIZE_IN_BYTES in decrypt and non-zero test data --- .../aztec-nr/aztec/src/messages/encoding.nr | 12 +- .../aztec/src/messages/encryption/aes128.nr | 193 ++++++++++++------ 2 files changed, 142 insertions(+), 63 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/messages/encoding.nr b/noir-projects/aztec-nr/aztec/src/messages/encoding.nr index 2b1fada3e64f..152ab8fab0fd 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/encoding.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/encoding.nr @@ -19,15 +19,15 @@ pub(crate) global AES128_PKCS7_EXPANSION_IN_BYTES: u32 = 16; pub global EPH_PK_X_SIZE_IN_FIELDS: u32 = 1; pub global EPH_PK_SIGN_BYTE_SIZE_IN_BYTES: u32 = 1; -// (15 - 1) * 31 - 16 - 1 = 417. We multiply by 31 because ciphertext bytes are stored in fields using +// (15 - 1) * 31 - 16 - 1 - 16 = 401. Note: We multiply by 31 because ciphertext bytes are stored in fields using // bytes_to_fields, which packs 31 bytes per field (since a Field is ~254 bits and can safely store 31 whole bytes). -global MESSAGE_PLAINTEXT_SIZE_IN_BYTES: u32 = (MESSAGE_CIPHERTEXT_LEN - EPH_PK_X_SIZE_IN_FIELDS) * 31 +pub(crate) global MESSAGE_PLAINTEXT_SIZE_IN_BYTES: u32 = (MESSAGE_CIPHERTEXT_LEN - EPH_PK_X_SIZE_IN_FIELDS) * 31 - HEADER_CIPHERTEXT_SIZE_IN_BYTES - - EPH_PK_SIGN_BYTE_SIZE_IN_BYTES; + - EPH_PK_SIGN_BYTE_SIZE_IN_BYTES + - AES128_PKCS7_EXPANSION_IN_BYTES; // The plaintext bytes represent Field values that were originally serialized using fields_to_bytes, which converts -// each Field to 32 bytes. To convert the plaintext bytes back to fields, we divide by 32. We must also account for -// AES PKCS#7 padding expansion that is always appended. (417 - 16) / 32 = 12 -pub global MESSAGE_PLAINTEXT_LEN: u32 = (MESSAGE_PLAINTEXT_SIZE_IN_BYTES - AES128_PKCS7_EXPANSION_IN_BYTES) / 32; +// each Field to 32 bytes. To convert the plaintext bytes back to fields, we divide by 32. 401 / 32 = 12 +pub global MESSAGE_PLAINTEXT_LEN: u32 = MESSAGE_PLAINTEXT_SIZE_IN_BYTES / 32; pub global MESSAGE_EXPANDED_METADATA_LEN: u32 = 1; diff --git a/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr b/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr index c5fbde2dd3ed..54073fdb8746 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr @@ -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, }, encryption::message_encryption::MessageEncryption, logs::arithmetic_generics_utils::{ @@ -150,6 +150,104 @@ 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 + /// + /// 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( plaintext: [Field; PlaintextLen], recipient: AztecAddress, @@ -158,9 +256,7 @@ impl MessageEncryption for AES128 { // 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 +285,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 +297,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 +314,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: + // Static check avoids the constraint cost of slice-to-array conversion. 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::(); // Safety: this randomness won't be constrained to be random. It's in the interest of the executor of this fn @@ -285,17 +364,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,21 +442,15 @@ 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 = + let ciphertext: BoundedVec = 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 message was serialized to 32 bytes so we convert back to fields. - // `fields_from_bytes` returns BoundedVec which - // may be larger than MESSAGE_PLAINTEXT_LEN, because MESSAGE_PLAINTEXT_SIZE_IN_BYTES is the - // raw byte capacity *before* subtracting AES PKCS#7 padding. After decryption the padding is - // stripped, so the actual content always fits in MESSAGE_PLAINTEXT_LEN fields. We shrink the - // capacity with subbvec to match the return type. - array::subbvec(fields_from_bytes(plaintext_bytes), 0) + fields_from_bytes(plaintext_bytes) }) } } @@ -411,7 +479,6 @@ mod test { keys::ecdh_shared_secret::derive_ecdh_shared_secret, messages::{encoding::MESSAGE_PLAINTEXT_LEN, encryption::message_encryption::MessageEncryption}, test::helpers::test_environment::TestEnvironment, - utils::conversion::fields_to_bytes::fields_to_bytes, }; use crate::protocol::{address::AztecAddress, traits::FromField}; use super::{AES128, random_address_point}; @@ -495,25 +562,37 @@ mod test { } #[test] - fn aes128_pkcs7_adds_full_block_for_field_aligned_input() { - // `fields_to_bytes` serializes each field to exactly 32 bytes, which is a multiple of - // the 16-byte AES block size. PKCS#7 therefore always appends a full 16-byte padding - // block. + fn pkcs7_padding_always_adds_at_least_one_byte() { let key = [0 as u8; 16]; let iv = [0 as u8; 16]; - let one_field_bytes = fields_to_bytes([0; 1]); - assert_eq(std::aes128::aes128_encrypt(one_field_bytes, iv, key).len(), 32 + 16); + // 1 byte input + 15 bytes padding = 16 bytes + assert_eq(std::aes128::aes128_encrypt([0; 1], iv, key).len(), 16); - let two_field_bytes = fields_to_bytes([0; 2]); - assert_eq(std::aes128::aes128_encrypt(two_field_bytes, iv, key).len(), 64 + 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_max_size_plaintext() { - let address = AztecAddress { inner: 3 }; - let plaintext: [Field; MESSAGE_PLAINTEXT_LEN] = [0; MESSAGE_PLAINTEXT_LEN]; - let _ = AES128::encrypt(plaintext, address); + 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)] From 910d46667be603b44172afcee13f07fe0fad3152 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Thu, 26 Feb 2026 12:32:02 -0300 Subject: [PATCH 3/4] fix(aztec-nr): add round-trip decode to max-size encode tests --- .../aztec-nr/aztec/src/messages/encoding.nr | 17 +++++++++++--- .../aztec/src/messages/encryption/aes128.nr | 3 +++ .../aztec-nr/aztec/src/messages/logs/note.nr | 23 ++++++++++++++++--- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/messages/encoding.nr b/noir-projects/aztec-nr/aztec/src/messages/encoding.nr index 152ab8fab0fd..d313a48a4764 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/encoding.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/encoding.nr @@ -250,9 +250,20 @@ mod tests { } #[test] - fn encode_max_size_message() { - let msg_content = [0; MAX_MESSAGE_CONTENT_LEN]; - let _ = encode_message(0, 0, msg_content); + unconstrained fn encode_decode_max_size_message() { + let msg_type_id: u64 = 42; + let msg_metadata: u64 = 99; + let mut msg_content = [0; MAX_MESSAGE_CONTENT_LEN]; + for i in 0..MAX_MESSAGE_CONTENT_LEN { + msg_content[i] = i as Field; + } + + let encoded = encode_message(msg_type_id, msg_metadata, msg_content); + let (decoded_type_id, decoded_metadata, decoded_content) = decode_message(BoundedVec::from_array(encoded)); + + assert_eq(decoded_type_id, msg_type_id); + assert_eq(decoded_metadata, msg_metadata); + assert_eq(decoded_content, BoundedVec::from_array(msg_content)); } #[test(should_fail_with = "Invalid message content: it must have a length of at most MAX_MESSAGE_CONTENT_LEN")] diff --git a/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr b/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr index 54073fdb8746..6d65db74d390 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr @@ -450,6 +450,8 @@ impl MessageEncryption for AES128 { // Decrypt main ciphertext and return it let plaintext_bytes = aes128_decrypt_oracle(ciphertext, body_iv, body_sym_key); + // Each field of the original message was serialized to 32 bytes so we convert + // the bytes back to fields. fields_from_bytes(plaintext_bytes) }) } @@ -561,6 +563,7 @@ 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]; diff --git a/noir-projects/aztec-nr/aztec/src/messages/logs/note.nr b/noir-projects/aztec-nr/aztec/src/messages/logs/note.nr index 89a81940aa38..84a72a48e534 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/logs/note.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/logs/note.nr @@ -134,9 +134,26 @@ mod test { } #[test] - fn encode_max_size_note() { - let note = MaxSizeNote { data: [0; MAX_NOTE_PACKED_LEN] }; - let _ = encode_private_note_message(note, OWNER, STORAGE_SLOT, RANDOMNESS); + unconstrained fn encode_decode_max_size_note() { + let mut data = [0; MAX_NOTE_PACKED_LEN]; + for i in 0..MAX_NOTE_PACKED_LEN { + data[i] = i as Field; + } + let note = MaxSizeNote { data }; + + let encoded = encode_private_note_message(note, OWNER, STORAGE_SLOT, RANDOMNESS); + let (msg_type_id, msg_metadata, msg_content) = decode_message(BoundedVec::from_array(encoded)); + + assert_eq(msg_type_id, PRIVATE_NOTE_MSG_TYPE_ID); + + let (note_type_id, owner, storage_slot, randomness, packed_note) = + decode_private_note_message(msg_metadata, msg_content); + + assert_eq(note_type_id, MaxSizeNote::get_id()); + assert_eq(owner, OWNER); + assert_eq(storage_slot, STORAGE_SLOT); + assert_eq(randomness, RANDOMNESS); + assert_eq(packed_note, BoundedVec::from_array(data)); } #[derive(Packable)] From 9b7cf949a9a703cb030c98df1e63267e5f365600 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Thu, 26 Feb 2026 16:52:03 -0300 Subject: [PATCH 4/4] fix(aztec-nr): address review feedback on encrypt doc comments and assertions --- .../aztec/src/messages/encryption/aes128.nr | 68 ++++++++----------- 1 file changed, 28 insertions(+), 40 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr b/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr index 6d65db74d390..58683f9c9217 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr @@ -155,32 +155,24 @@ impl MessageEncryption for AES128 { /// /// ## Overview /// - /// 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. + /// 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: + /// 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) + /// - 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. + /// 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): + /// **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 /// +---------------------------------------------+ @@ -192,11 +184,9 @@ impl MessageEncryption for AES128 { /// +-------------------------------+--------------+ /// ``` /// - /// **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: + /// **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 /// +---------------------------+ @@ -211,14 +201,11 @@ impl MessageEncryption for AES128 { /// /// ## 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. + /// 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: + /// **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 /// +---------+------------+-------------------------+---------+ @@ -228,11 +215,9 @@ impl MessageEncryption for AES128 { /// |<----------- 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: + /// **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 /// +----------+-------------------------+-------------------+ @@ -244,14 +229,17 @@ impl MessageEncryption for AES128 { /// /// ## 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. + /// 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( 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); @@ -314,7 +302,7 @@ 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); - // Static check avoids the constraint cost of slice-to-array conversion. + // Verify expected header ciphertext size at compile time. std::static_assert( header_ciphertext_bytes.len() == HEADER_CIPHERTEXT_SIZE_IN_BYTES, "unexpected ciphertext header length", @@ -598,7 +586,7 @@ mod test { }); } - #[test(should_fail)] + #[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];