Skip to content

Commit 10197f4

Browse files
authored
fix(aztec-nr): account for AES PKCS#7 padding in message plaintext length (#20840)
1 parent 052d8a1 commit 10197f4

10 files changed

Lines changed: 235 additions & 57 deletions

File tree

noir-projects/aztec-nr/aztec/src/messages/encoding.nr

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,24 @@ use crate::utils::array;
99
// fields, so MESSAGE_CIPHERTEXT_LEN is the size of the message in fields.
1010
pub global MESSAGE_CIPHERTEXT_LEN: u32 = PRIVATE_LOG_CIPHERTEXT_LEN;
1111

12-
// TODO(#12750): The global variables below should not be here as they are AES128 specific. ciphertext_length (2) + 14
13-
// bytes pkcs#7 AES padding.
12+
// TODO(#12750): The global variables below should not be here as they are AES128 specific.
13+
// The header plaintext is 2 bytes (ciphertext length), padded to the 16-byte AES block size by PKCS#7.
1414
pub(crate) global HEADER_CIPHERTEXT_SIZE_IN_BYTES: u32 = 16;
15+
// AES PKCS#7 always adds at least one byte of padding. Since each plaintext field is 32 bytes (a multiple of the
16+
// 16-byte AES block size), a full 16-byte padding block is always appended.
17+
pub(crate) global AES128_PKCS7_EXPANSION_IN_BYTES: u32 = 16;
1518

1619
pub global EPH_PK_X_SIZE_IN_FIELDS: u32 = 1;
1720
pub global EPH_PK_SIGN_BYTE_SIZE_IN_BYTES: u32 = 1;
1821

19-
// (17 - 1) * 31 - 16 - 1 = 479 Note: We multiply by 31 because ciphertext bytes are stored in fields using
22+
// (15 - 1) * 31 - 16 - 1 - 16 = 401. Note: We multiply by 31 because ciphertext bytes are stored in fields using
2023
// bytes_to_fields, which packs 31 bytes per field (since a Field is ~254 bits and can safely store 31 whole bytes).
21-
global MESSAGE_PLAINTEXT_SIZE_IN_BYTES: u32 = (MESSAGE_CIPHERTEXT_LEN - EPH_PK_X_SIZE_IN_FIELDS) * 31
24+
pub(crate) global MESSAGE_PLAINTEXT_SIZE_IN_BYTES: u32 = (MESSAGE_CIPHERTEXT_LEN - EPH_PK_X_SIZE_IN_FIELDS) * 31
2225
- HEADER_CIPHERTEXT_SIZE_IN_BYTES
23-
- EPH_PK_SIGN_BYTE_SIZE_IN_BYTES;
26+
- EPH_PK_SIGN_BYTE_SIZE_IN_BYTES
27+
- AES128_PKCS7_EXPANSION_IN_BYTES;
2428
// The plaintext bytes represent Field values that were originally serialized using fields_to_bytes, which converts
25-
// each Field to 32 bytes. To convert the plaintext bytes back to fields, we divide by 32. 479 / 32 = 14
29+
// each Field to 32 bytes. To convert the plaintext bytes back to fields, we divide by 32. 401 / 32 = 12
2630
pub global MESSAGE_PLAINTEXT_LEN: u32 = MESSAGE_PLAINTEXT_SIZE_IN_BYTES / 32;
2731

2832
pub global MESSAGE_EXPANDED_METADATA_LEN: u32 = 1;
@@ -244,4 +248,27 @@ mod tests {
244248
assert_eq(original_msg_type, unpacked_msg_type);
245249
assert_eq(original_msg_metadata, unpacked_msg_metadata);
246250
}
251+
252+
#[test]
253+
unconstrained fn encode_decode_max_size_message() {
254+
let msg_type_id: u64 = 42;
255+
let msg_metadata: u64 = 99;
256+
let mut msg_content = [0; MAX_MESSAGE_CONTENT_LEN];
257+
for i in 0..MAX_MESSAGE_CONTENT_LEN {
258+
msg_content[i] = i as Field;
259+
}
260+
261+
let encoded = encode_message(msg_type_id, msg_metadata, msg_content);
262+
let (decoded_type_id, decoded_metadata, decoded_content) = decode_message(BoundedVec::from_array(encoded));
263+
264+
assert_eq(decoded_type_id, msg_type_id);
265+
assert_eq(decoded_metadata, msg_metadata);
266+
assert_eq(decoded_content, BoundedVec::from_array(msg_content));
267+
}
268+
269+
#[test(should_fail_with = "Invalid message content: it must have a length of at most MAX_MESSAGE_CONTENT_LEN")]
270+
fn encode_oversized_message_fails() {
271+
let msg_content = [0; MAX_MESSAGE_CONTENT_LEN + 1];
272+
let _ = encode_message(0, 0, msg_content);
273+
}
247274
}

noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr

Lines changed: 143 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -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
150150
pub struct AES128 {}
151151

152152
impl 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

noir-projects/aztec-nr/aztec/src/messages/logs/note.nr

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ mod test {
8989
use crate::{
9090
messages::{
9191
encoding::decode_message,
92-
logs::note::{decode_private_note_message, encode_private_note_message},
92+
logs::note::{decode_private_note_message, encode_private_note_message, MAX_NOTE_PACKED_LEN},
9393
msg_type::PRIVATE_NOTE_MSG_TYPE_ID,
9494
},
9595
note::note_interface::NoteType,
@@ -121,4 +121,55 @@ mod test {
121121
assert_eq(randomness, RANDOMNESS);
122122
assert_eq(packed_note, BoundedVec::from_array(note.pack()));
123123
}
124+
125+
#[derive(Packable)]
126+
struct MaxSizeNote {
127+
data: [Field; MAX_NOTE_PACKED_LEN],
128+
}
129+
130+
impl NoteType for MaxSizeNote {
131+
fn get_id() -> Field {
132+
0
133+
}
134+
}
135+
136+
#[test]
137+
unconstrained fn encode_decode_max_size_note() {
138+
let mut data = [0; MAX_NOTE_PACKED_LEN];
139+
for i in 0..MAX_NOTE_PACKED_LEN {
140+
data[i] = i as Field;
141+
}
142+
let note = MaxSizeNote { data };
143+
144+
let encoded = encode_private_note_message(note, OWNER, STORAGE_SLOT, RANDOMNESS);
145+
let (msg_type_id, msg_metadata, msg_content) = decode_message(BoundedVec::from_array(encoded));
146+
147+
assert_eq(msg_type_id, PRIVATE_NOTE_MSG_TYPE_ID);
148+
149+
let (note_type_id, owner, storage_slot, randomness, packed_note) =
150+
decode_private_note_message(msg_metadata, msg_content);
151+
152+
assert_eq(note_type_id, MaxSizeNote::get_id());
153+
assert_eq(owner, OWNER);
154+
assert_eq(storage_slot, STORAGE_SLOT);
155+
assert_eq(randomness, RANDOMNESS);
156+
assert_eq(packed_note, BoundedVec::from_array(data));
157+
}
158+
159+
#[derive(Packable)]
160+
struct OversizedNote {
161+
data: [Field; MAX_NOTE_PACKED_LEN + 1],
162+
}
163+
164+
impl NoteType for OversizedNote {
165+
fn get_id() -> Field {
166+
0
167+
}
168+
}
169+
170+
#[test(should_fail_with = "Invalid message content: it must have a length of at most MAX_MESSAGE_CONTENT_LEN")]
171+
fn encode_oversized_note_fails() {
172+
let note = OversizedNote { data: [0; MAX_NOTE_PACKED_LEN + 1] };
173+
let _ = encode_private_note_message(note, OWNER, STORAGE_SLOT, RANDOMNESS);
174+
}
124175
}

noir-projects/aztec-nr/aztec/src/messages/processing/event_validation_request.nr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ mod test {
4242
3, // randomness
4343
4, // serialized_event[0]
4444
5, // serialized_event[1]
45-
0, 0, 0, 0, 0, 0, 0, 0, 0, // serialized_event padding
45+
0, 0, 0, 0, 0, 0, 0, 0, // serialized_event padding
4646
2, // bounded_vec_len
4747
6, // event_commitment
4848
7, // tx_hash

0 commit comments

Comments
 (0)