aztec-nr - noir_aztec::messages::encryption::aes128

Struct AES128

pub struct AES128 {}

Trait implementations

impl MessageEncryption for AES128

pub fn encrypt<let PlaintextLen: u32>( plaintext: [Field; PlaintextLen], recipient: AztecAddress, ) -> [Field; 15]

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):

+---------------------------------------------+
|                   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:

+---------------------------+
|         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:

+------------+-------------------------+---------+
| header ct  |        body ct          | byte pad|
|   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:

+----------+-------------------------+-------------------+
| 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.

pub unconstrained fn decrypt( ciphertext: BoundedVec<Field, 15>, recipient: AztecAddress, ) -> Option<BoundedVec<Field, 12>>