@@ -11,7 +11,7 @@ use crate::{
1111 messages ::{
1212 encoding ::{
1313 EPH_PK_SIGN_BYTE_SIZE_IN_BYTES , EPH_PK_X_SIZE_IN_FIELDS , HEADER_CIPHERTEXT_SIZE_IN_BYTES ,
14- MESSAGE_CIPHERTEXT_LEN , MESSAGE_PLAINTEXT_LEN ,
14+ MESSAGE_CIPHERTEXT_LEN , MESSAGE_PLAINTEXT_LEN , MESSAGE_PLAINTEXT_SIZE_IN_BYTES ,
1515 },
1616 encryption::message_encryption::MessageEncryption ,
1717 logs:: arithmetic_generics_utils ::{
@@ -150,17 +150,101 @@ pub fn derive_aes_symmetric_key_and_iv_from_ecdh_shared_secret_using_poseidon2_u
150150pub struct AES128 {}
151151
152152impl MessageEncryption for AES128 {
153+
154+ /// AES128-CBC encryption for Aztec protocol messages.
155+ ///
156+ /// ## Overview
157+ ///
158+ /// The plaintext is an array of up to `MESSAGE_PLAINTEXT_LEN` (12) fields. The output is always exactly
159+ /// `MESSAGE_CIPHERTEXT_LEN` (15) fields, regardless of plaintext size. Unused trailing fields are filled with
160+ /// random data so that all encrypted messages are indistinguishable by size.
161+ ///
162+ /// ## PKCS#7 Padding
163+ ///
164+ /// AES operates on 16-byte blocks, so the plaintext must be padded to a multiple of 16. PKCS#7 padding always
165+ /// adds at least 1 byte (so the receiver can always detect and strip it), which means:
166+ /// - 1 B plaintext -> 15 B padding -> 16 B total
167+ /// - 15 B plaintext -> 1 B padding -> 16 B total
168+ /// - 16 B plaintext -> 16 B padding -> 32 B total (full extra block)
169+ ///
170+ /// In general: if the plaintext is already a multiple of 16, a full 16-byte padding block is appended.
171+ ///
172+ /// ## Encryption Steps
173+ ///
174+ /// **1. Body encryption.** The plaintext fields are serialized to bytes (32 bytes per field) and AES-128-CBC
175+ /// encrypted. Since 32 is a multiple of 16, PKCS#7 always adds a full 16-byte padding block (see above):
176+ ///
177+ /// ```text
178+ /// +---------------------------------------------+
179+ /// | body ct |
180+ /// | PlaintextLen*32 + 16 B |
181+ /// +-------------------------------+--------------+
182+ /// | encrypted plaintext fields | PKCS#7 (16B) |
183+ /// | (serialized at 32 B each) | |
184+ /// +-------------------------------+--------------+
185+ /// ```
186+ ///
187+ /// **2. Header encryption.** The byte length of `body_ct` is stored as a 2-byte big-endian integer. This 2-byte
188+ /// header plaintext is then AES-encrypted; PKCS#7 pads the remaining 14 bytes to fill one 16-byte AES block,
189+ /// producing a 16-byte header ciphertext:
190+ ///
191+ /// ```text
192+ /// +---------------------------+
193+ /// | header ct |
194+ /// | 16 B |
195+ /// +--------+------------------+
196+ /// | body ct| PKCS#7 (14B) |
197+ /// | length | |
198+ /// | (2 B) | |
199+ /// +--------+------------------+
200+ /// ```
201+ ///
202+ /// ## Wire Format
203+ ///
204+ /// Messages are transmitted as fields, not bytes. A field is ~254 bits and can safely store 31 whole bytes, so
205+ /// we need to pack our byte data into 31-byte chunks. This packing drives the wire format.
206+ ///
207+ /// **Step 1 -- Assemble bytes.** The ciphertexts are laid out in a byte array, padded with random bytes to a
208+ /// multiple of 31 so it divides evenly into fields:
209+ ///
210+ /// ```text
211+ /// +---------+------------+-------------------------+---------+
212+ /// | pk sign | header ct | body ct | byte pad|
213+ /// | 1 B | 16 B | PlaintextLen*32 + 16 B | (random)|
214+ /// +---------+------------+-------------------------+---------+
215+ /// |<----------- padded to a multiple of 31 B ------------->|
216+ /// ```
217+ ///
218+ /// **Step 2 -- Pack into fields.** The byte array is split into 31-byte chunks, each stored in one field. The
219+ /// ephemeral public key x-coordinate is prepended as its own field. Any remaining fields (up to 15 total) are
220+ /// filled with random data so that all messages are the same size:
221+ ///
222+ /// ```text
223+ /// +----------+-------------------------+-------------------+
224+ /// | eph_pk.x | message-byte fields | random field pad |
225+ /// | | (packed 31 B per field) | (fills to 15) |
226+ /// +----------+-------------------------+-------------------+
227+ /// |<---------- MESSAGE_CIPHERTEXT_LEN = 15 fields ------->|
228+ /// ```
229+ ///
230+ /// ## Key Derivation
231+ ///
232+ /// Two (key, IV) pairs are derived from the ECDH shared secret via Poseidon2 hashing with different domain
233+ /// separators: one pair for the body ciphertext and one for the header ciphertext.
153234 fn encrypt <let PlaintextLen : u32 >(
154235 plaintext : [Field ; PlaintextLen ],
155236 recipient : AztecAddress ,
156237 ) -> [Field ; MESSAGE_CIPHERTEXT_LEN ] {
238+ std:: static_assert (
239+ PlaintextLen <= MESSAGE_PLAINTEXT_LEN ,
240+ "Plaintext length exceeds MESSAGE_PLAINTEXT_LEN" ,
241+ );
242+
157243 // AES 128 operates on bytes, not fields, so we need to convert the fields to bytes. (This process is then
158244 // reversed when processing the message in `process_message_ciphertext`)
159245 let plaintext_bytes = fields_to_bytes (plaintext );
160246
161- // ***************************************************************************** Compute the shared secret
162- // *****************************************************************************
163-
247+ // Derive ECDH shared secret with recipient using a fresh ephemeral keypair.
164248 let (eph_sk , eph_pk ) = generate_ephemeral_key_pair ();
165249
166250 let eph_pk_sign_byte : u8 = get_sign_of_point (eph_pk ) as u8 ;
@@ -189,15 +273,7 @@ impl MessageEncryption for AES128 {
189273 );
190274 // TODO: also use this shared secret for deriving note randomness.
191275
192- // ***************************************************************************** Convert the plaintext into
193- // whatever format the encryption function expects
194- // *****************************************************************************
195-
196- // Already done for this strategy: AES expects bytes.
197-
198- // ***************************************************************************** Encrypt the plaintext
199- // *****************************************************************************
200-
276+ // AES128-CBC encrypt the plaintext bytes.
201277 // It is safe to call the `unsafe` function here, because we know the `shared_secret` was derived using an
202278 // AztecAddress (the recipient). See the block comment at the start of this unsafe target function for more
203279 // info.
@@ -209,22 +285,15 @@ impl MessageEncryption for AES128 {
209285
210286 let ciphertext_bytes = aes128_encrypt (plaintext_bytes , body_iv , body_sym_key );
211287
212- // |full_pt| = |pt_length| + |pt|
213- // |pt_aes_padding| = 16 - (|full_pt| % 16)
214- // or... since a % b is the same as a - b * (a // b) (integer division), so:
215- // |pt_aes_padding| = 16 - (|full_pt| - 16 * (|full_pt| // 16))
216- // |ct| = |full_pt| + |pt_aes_padding|
217- // = |full_pt| + 16 - (|full_pt| - 16 * (|full_pt| // 16)) = 16 + 16 * (|full_pt| // 16) = 16 * (1 +
218- // |full_pt| // 16)
288+ // Each plaintext field is 32 bytes (a multiple of the 16-byte AES block
289+ // size), so PKCS#7 always appends a full 16-byte padding block:
290+ // |ciphertext| = PlaintextLen*32 + 16 = 16 * (1 + PlaintextLen*32 / 16)
219291 std:: static_assert (
220292 ciphertext_bytes .len () == 16 * (1 + (PlaintextLen * 32 ) / 16 ),
221293 "unexpected ciphertext length" ,
222294 );
223295
224- // ***************************************************************************** Compute the header ciphertext
225- // *****************************************************************************
226-
227- // Header contains only the length of the ciphertext stored in 2 bytes.
296+ // Encrypt a 2-byte header containing the body ciphertext length.
228297 let mut header_plaintext : [u8 ; 2 ] = [0 as u8 ; 2 ];
229298 let ciphertext_bytes_length = ciphertext_bytes .len ();
230299 header_plaintext [0 ] = (ciphertext_bytes_length >> 8 ) as u8 ;
@@ -233,16 +302,14 @@ impl MessageEncryption for AES128 {
233302 // Note: the aes128_encrypt builtin fn automatically appends bytes to the input, according to pkcs#7; hence why
234303 // the output `header_ciphertext_bytes` is 16 bytes larger than the input in this case.
235304 let header_ciphertext_bytes = aes128_encrypt (header_plaintext , header_iv , header_sym_key );
236- // I recall that converting a slice to an array incurs constraints, so I'll check the length this way instead:
305+ // Verify expected header ciphertext size at compile time.
237306 std:: static_assert (
238307 header_ciphertext_bytes .len () == HEADER_CIPHERTEXT_SIZE_IN_BYTES ,
239308 "unexpected ciphertext header length" ,
240309 );
241310
242- // ***************************************************************************** Prepend / append more bytes of
243- // data to the ciphertext, before converting back to fields.
244- // *****************************************************************************
245-
311+ // Assemble the message byte array:
312+ // [eph_pk_sign (1B)] [header_ct (16B)] [body_ct] [padding to mult of 31]
246313 let mut message_bytes_padding_to_mult_31 =
247314 get_arr_of_size__message_bytes_padding__from_PT ::<PlaintextLen * 32 >();
248315 // 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 {
285352 );
286353 assert (offset == message_bytes .len (), "unexpected encrypted message length" );
287354
288- // ***************************************************************************** Convert bytes back to fields
289- // *****************************************************************************
290-
355+ // Pack message bytes into fields (31 bytes per field) and prepend eph_pk.x.
291356 // TODO(#12749): As Mike pointed out, we need to make messages produced by different encryption schemes
292357 // indistinguishable from each other and for this reason the output here and in the last for-loop of this
293358 // function should cover a full field.
294359 let message_bytes_as_fields = bytes_to_fields (message_bytes );
295360
296- // ***************************************************************************** Prepend / append fields, to
297- // create the final message *****************************************************************************
298-
299361 let mut ciphertext : [Field ; MESSAGE_CIPHERTEXT_LEN ] = [0 ; MESSAGE_CIPHERTEXT_LEN ];
300362
301363 ciphertext [0 ] = eph_pk .x ;
@@ -368,16 +430,16 @@ impl MessageEncryption for AES128 {
368430
369431 // Extract and decrypt main ciphertext
370432 let ciphertext_start = header_start + HEADER_CIPHERTEXT_SIZE_IN_BYTES ;
371- 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 ] =
433+ let ciphertext_with_padding : [u8 ; MESSAGE_PLAINTEXT_SIZE_IN_BYTES ] =
372434 array:: subarray (ciphertext_without_eph_pk_x .storage (), ciphertext_start );
373- 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 > =
435+ let ciphertext : BoundedVec <u8 , MESSAGE_PLAINTEXT_SIZE_IN_BYTES > =
374436 BoundedVec ::from_parts (ciphertext_with_padding , ciphertext_length );
375437
376438 // Decrypt main ciphertext and return it
377439 let plaintext_bytes = aes128_decrypt_oracle (ciphertext , body_iv , body_sym_key );
378440
379- // Each field of the original note message was serialized to 32 bytes so we convert the bytes back to
380- // fields.
441+ // Each field of the original message was serialized to 32 bytes so we convert
442+ // the bytes back to fields.
381443 fields_from_bytes (plaintext_bytes )
382444 })
383445 }
@@ -489,6 +551,48 @@ mod test {
489551 let _ = AES128 ::encrypt ([1 , 2 , 3 , 4 ], invalid_address );
490552 }
491553
554+ // Documents the PKCS#7 padding behavior that `encrypt` relies on (see its static_assert).
555+ #[test]
556+ fn pkcs7_padding_always_adds_at_least_one_byte () {
557+ let key = [0 as u8 ; 16 ];
558+ let iv = [0 as u8 ; 16 ];
559+
560+ // 1 byte input + 15 bytes padding = 16 bytes
561+ assert_eq (std::aes128:: aes128_encrypt ([0 ; 1 ], iv , key ).len (), 16 );
562+
563+ // 15 bytes input + 1 byte padding = 16 bytes
564+ assert_eq (std::aes128:: aes128_encrypt ([0 ; 15 ], iv , key ).len (), 16 );
565+
566+ // 16 bytes input (block-aligned) + full 16-byte padding block = 32 bytes
567+ assert_eq (std::aes128:: aes128_encrypt ([0 ; 16 ], iv , key ).len (), 32 );
568+ }
569+
570+ #[test]
571+ unconstrained fn encrypt_decrypt_max_size_plaintext () {
572+ let mut env = TestEnvironment ::new ();
573+ let recipient = env .create_light_account ();
574+
575+ env .private_context (|_ | {
576+ let mut plaintext = [0 ; MESSAGE_PLAINTEXT_LEN ];
577+ for i in 0 ..MESSAGE_PLAINTEXT_LEN {
578+ plaintext [i ] = i as Field ;
579+ }
580+ let ciphertext = AES128 ::encrypt (plaintext , recipient );
581+
582+ assert_eq (
583+ AES128 ::decrypt (BoundedVec ::from_array (ciphertext ), recipient ).unwrap (),
584+ BoundedVec ::from_array (plaintext ),
585+ );
586+ });
587+ }
588+
589+ #[test(should_fail_with = "Plaintext length exceeds MESSAGE_PLAINTEXT_LEN")]
590+ unconstrained fn encrypt_oversized_plaintext () {
591+ let address = AztecAddress { inner : 3 };
592+ let plaintext : [Field ; MESSAGE_PLAINTEXT_LEN + 1 ] = [0 ; MESSAGE_PLAINTEXT_LEN + 1 ];
593+ let _ = AES128 ::encrypt (plaintext , address );
594+ }
595+
492596 #[test]
493597 unconstrained fn random_address_point_produces_valid_points () {
494598 // About half of random addresses are invalid, so testing just a couple gives us high confidence that
0 commit comments