diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index e38996d04e99..0ca2e810a3ea 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,6 +9,10 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD +### [Aztec.nr] Removed `get_random_bytes` + +The `get_random_bytes` unconstrained function has been removed from `aztec::utils::random`. If you were using it, you can replace it with direct calls to the `random` oracle from `aztec::oracle::random` and convert to bytes yourself. + ### [Aztec.js] `simulate()`, `send()`, and deploy return types changed to always return objects All SDK interaction methods now return structured objects that include offchain output alongside the primary result. This affects `.simulate()`, `.send()`, deploy `.send()`, and `Wallet.sendTx()`. 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 b73fcb96e135..fa37326877f2 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr @@ -1,6 +1,6 @@ use crate::protocol::{ address::AztecAddress, - constants::{DOM_SEP__SYMMETRIC_KEY, DOM_SEP__SYMMETRIC_KEY_2}, + constants::{DOM_SEP__CIPHERTEXT_FIELD_MASK, DOM_SEP__SYMMETRIC_KEY, DOM_SEP__SYMMETRIC_KEY_2}, hash::poseidon2_hash_with_separator, point::Point, public_keys::AddressPoint, @@ -26,7 +26,6 @@ use crate::{ fields_to_bytes::{fields_from_bytes, fields_to_bytes}, }, point::point_from_x_coord_and_sign, - random::get_random_bytes, }, }; @@ -156,8 +155,9 @@ 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. + /// `MESSAGE_CIPHERTEXT_LEN` (15) fields, regardless of plaintext size. All output fields except the + /// ephemeral public key are uniformly random `Field` values to any observer without knowledge of the + /// shared secret, making all encrypted messages indistinguishable by size or content. /// /// ## PKCS#7 Padding /// @@ -204,27 +204,31 @@ impl MessageEncryption for AES128 { /// 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 + /// **Step 1 -- Assemble bytes.** The ciphertexts are laid out in a byte array, padded with zero bytes to a /// multiple of 31 so it divides evenly into fields: /// /// ```text /// +------------+-------------------------+---------+ /// | header ct | body ct | byte pad| - /// | 16 B | PlaintextLen*32 + 16 B | (random)| + /// | 16 B | PlaintextLen*32 + 16 B | (zeros) | /// +------------+-------------------------+---------+ /// |<-------- 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 and mask.** The byte array is split into 31-byte chunks, each stored in one field. A + /// Poseidon2-derived mask (see `derive_field_mask`) is added to each so that the resulting fields appear as + /// uniformly random `Field` values to any observer without knowledge of the shared secret, hiding the fact + /// that the underlying ciphertext consists of 128-bit AES blocks. + /// + /// **Step 3 -- Assemble ciphertext.** The ephemeral public key x-coordinate is prepended and random field padding + /// is appended to fill to 15 fields: /// /// ```text /// +----------+-------------------------+-------------------+ - /// | eph_pk.x | message-byte fields | random field pad | - /// | | (packed 31 B per field) | (fills to 15) | + /// | eph_pk.x | masked message fields | random field pad | + /// | | (packed 31 B per field) | (fills to 15) | /// +----------+-------------------------+-------------------+ - /// |<---------- MESSAGE_CIPHERTEXT_LEN = 15 fields ------->| + /// |<---------- MESSAGE_CIPHERTEXT_LEN = 15 fields -------->| /// ``` /// /// ## Key Derivation @@ -304,11 +308,7 @@ impl MessageEncryption for AES128 { // Assemble the message byte array: // [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 - // to encrypt with random bytes. - message_bytes_padding_to_mult_31 = unsafe { get_random_bytes() }; + let message_bytes_padding_to_mult_31 = get_arr_of_size__message_bytes_padding__from_PT::(); let mut message_bytes = get_arr_of_size__message_bytes__from_PT::(); @@ -346,30 +346,26 @@ impl MessageEncryption for AES128 { assert(offset == message_bytes.len(), "unexpected encrypted message length"); // 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); let mut ciphertext: [Field; MESSAGE_CIPHERTEXT_LEN] = [0; MESSAGE_CIPHERTEXT_LEN]; ciphertext[0] = eph_pk.x; + // Mask each content field with a Poseidon2-derived value, so that they appear as uniformly random `Field` + // values let mut offset = 1; for i in 0..message_bytes_as_fields.len() { - ciphertext[offset + i] = message_bytes_as_fields[i]; + let mask = derive_field_mask(ciphertext_shared_secret, i as u32); + ciphertext[offset + i] = message_bytes_as_fields[i] + mask; } offset += message_bytes_as_fields.len(); + // Pad with random fields so that padding is indistinguishable from masked data fields. for i in offset..MESSAGE_CIPHERTEXT_LEN { - // We need to get a random value that fits in 31 bytes to not leak information about the size of the - // message (all the "real" message fields contain at most 31 bytes because of the way we convert the bytes - // to fields). TODO(#12749): Long term, this is not a good solution. - // Safety: we assume that the sender wants for the message to be private - a malicious one could simply // reveal its contents publicly. It is therefore fine to trust the sender to provide random padding. - let field_bytes = unsafe { get_random_bytes::<31>() }; - ciphertext[i] = Field::from_be_bytes::<31>(field_bytes); + ciphertext[i] = unsafe { random() }; } ciphertext @@ -381,14 +377,11 @@ impl MessageEncryption for AES128 { ) -> Option> { let eph_pk_x = ciphertext.get(0); - let ciphertext_without_eph_pk_x_fields = array::subbvec::( + let masked_fields = array::subbvec::( ciphertext, EPH_PK_X_SIZE_IN_FIELDS, ); - // Convert the ciphertext represented as fields to a byte representation (its original format) - let ciphertext_without_eph_pk_x = bytes_from_fields(ciphertext_without_eph_pk_x_fields); - // With the x-coordinate of the ephemeral public key we can reconstruct the point as we know that the // y-coordinate must be positive. This may fail however, as not all x-coordinates are on the curve. In that // case, we simply return `Option::none`. @@ -396,6 +389,14 @@ impl MessageEncryption for AES128 { // Derive shared secret let ciphertext_shared_secret = get_shared_secret(recipient, eph_pk); + let unmasked_fields = masked_fields.mapi(|i, field| { + let unmasked = unmask_field(ciphertext_shared_secret, i, field); + // If we failed to unmask the field, we are dealing with the random padding. We'll ignore it later, + // so we can simply set it to 0 + unmasked.unwrap_or(0) + }); + let ciphertext_without_eph_pk_x = bytes_from_fields(unmasked_fields); + // Derive symmetric keys: let pairs = derive_aes_symmetric_key_and_iv_from_ecdh_shared_secret_using_poseidon2_unsafe::<2>( ciphertext_shared_secret, @@ -436,6 +437,30 @@ impl MessageEncryption for AES128 { } } +/// 2^248: upper bound for values that fit in 31 bytes +global TWO_POW_248: Field = 2.pow_32(248); + +/// Removes the Poseidon2-derived mask from a ciphertext field. Returns the unmasked value if it fits in 31 bytes +/// (a content field), or `None` if it doesn't (random padding). Unconstrained to prevent accidental use in +/// constrained context. +unconstrained fn unmask_field(shared_secret: Point, index: u32, masked: Field) -> Option { + let unmasked = masked - derive_field_mask(shared_secret, index); + if unmasked.lt(TWO_POW_248) { + Option::some(unmasked) + } else { + Option::none() + } +} + +/// Derives a field mask from an ECDH shared secret and field index. Applied only to data fields (those carrying +/// packed message bytes). Padding fields use `random()` instead. +fn derive_field_mask(shared_secret: Point, index: u32) -> Field { + poseidon2_hash_with_separator( + [shared_secret.x, shared_secret.y], + DOM_SEP__CIPHERTEXT_FIELD_MASK + index, + ) +} + /// Produces a random valid address point, i.e. one that is on the curve. This is equivalent to calling /// [`AztecAddress::to_address_point`] on a random valid address. unconstrained fn random_address_point() -> AddressPoint { diff --git a/noir-projects/aztec-nr/aztec/src/utils/mod.nr b/noir-projects/aztec-nr/aztec/src/utils/mod.nr index 0e1b8653a442..f708bd7ad09c 100644 --- a/noir-projects/aztec-nr/aztec/src/utils/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/utils/mod.nr @@ -4,7 +4,6 @@ pub mod array; pub mod comparison; pub mod conversion; pub mod point; -pub mod random; mod with_hash; pub use with_hash::WithHash; pub mod remove_constraints; diff --git a/noir-projects/aztec-nr/aztec/src/utils/random.nr b/noir-projects/aztec-nr/aztec/src/utils/random.nr deleted file mode 100644 index 6cb65f361058..000000000000 --- a/noir-projects/aztec-nr/aztec/src/utils/random.nr +++ /dev/null @@ -1,17 +0,0 @@ -use crate::oracle::random::random; - -/// Returns as many random bytes as specified through N. -pub unconstrained fn get_random_bytes() -> [u8; N] { - let mut bytes = [0; N]; - let mut idx = 32; - let mut randomness = [0; 32]; - for i in 0..N { - if idx == 32 { - randomness = random().to_be_bytes(); - idx = 1; // Skip the first byte as it's always 0. - } - bytes[i] = randomness[idx]; - idx += 1; - } - bytes -} diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr b/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr index 33eebf46284e..6100302d21ee 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr @@ -705,6 +705,7 @@ pub global DOM_SEP__AUTHWIT_NULLIFIER: u32 = 1239150694; pub global DOM_SEP__SYMMETRIC_KEY: u32 = 3882206064; pub global DOM_SEP__SYMMETRIC_KEY_2: u32 = 4129434989; +pub global DOM_SEP__CIPHERTEXT_FIELD_MASK: u32 = 1870492847; pub global DOM_SEP__PARTIAL_NOTE_VALIDITY_COMMITMENT: u32 = 623934423; diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr b/noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr index 699edf5b8b84..6f7c9dee1cc1 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr @@ -7,19 +7,19 @@ use crate::{ CONTRACT_CLASS_REGISTRY_UTILITY_FUNCTION_BROADCASTED_MAGIC_VALUE, CONTRACT_INSTANCE_PUBLISHED_MAGIC_VALUE, CONTRACT_INSTANCE_UPDATED_MAGIC_VALUE, DOM_SEP__AUTHWIT_INNER, DOM_SEP__AUTHWIT_NULLIFIER, DOM_SEP__AUTHWIT_OUTER, - DOM_SEP__BLOCK_HEADER_HASH, DOM_SEP__CONTRACT_ADDRESS_V1, DOM_SEP__CONTRACT_CLASS_ID, - DOM_SEP__EVENT_COMMITMENT, DOM_SEP__FUNCTION_ARGS, DOM_SEP__INITIALIZATION_NULLIFIER, - DOM_SEP__INITIALIZER, DOM_SEP__IVSK_M, DOM_SEP__MESSAGE_NULLIFIER, DOM_SEP__NHK_M, - DOM_SEP__NOTE_HASH, DOM_SEP__NOTE_HASH_NONCE, DOM_SEP__NOTE_NULLIFIER, DOM_SEP__OVSK_M, - DOM_SEP__PARTIAL_ADDRESS, DOM_SEP__PARTIAL_NOTE_VALIDITY_COMMITMENT, - DOM_SEP__PRIVATE_FUNCTION_LEAF, DOM_SEP__PRIVATE_LOG_FIRST_FIELD, DOM_SEP__PRIVATE_TX_HASH, - DOM_SEP__PROTOCOL_CONTRACTS, DOM_SEP__PUBLIC_BYTECODE, DOM_SEP__PUBLIC_CALLDATA, - DOM_SEP__PUBLIC_KEYS_HASH, DOM_SEP__PUBLIC_LEAF_SLOT, DOM_SEP__PUBLIC_STORAGE_MAP_SLOT, - DOM_SEP__PUBLIC_TX_HASH, DOM_SEP__SECRET_HASH, DOM_SEP__SIGNATURE_PAYLOAD, - DOM_SEP__SILOED_NOTE_HASH, DOM_SEP__SILOED_NULLIFIER, DOM_SEP__SYMMETRIC_KEY, - DOM_SEP__SYMMETRIC_KEY_2, DOM_SEP__TSK_M, DOM_SEP__TX_NULLIFIER, DOM_SEP__TX_REQUEST, - DOM_SEP__UNIQUE_NOTE_HASH, NULL_MSG_SENDER_CONTRACT_ADDRESS, SIDE_EFFECT_MASKING_ADDRESS, - TX_START_PREFIX, + DOM_SEP__BLOCK_HEADER_HASH, DOM_SEP__CIPHERTEXT_FIELD_MASK, DOM_SEP__CONTRACT_ADDRESS_V1, + DOM_SEP__CONTRACT_CLASS_ID, DOM_SEP__EVENT_COMMITMENT, DOM_SEP__FUNCTION_ARGS, + DOM_SEP__INITIALIZATION_NULLIFIER, DOM_SEP__INITIALIZER, DOM_SEP__IVSK_M, + DOM_SEP__MESSAGE_NULLIFIER, DOM_SEP__NHK_M, DOM_SEP__NOTE_HASH, DOM_SEP__NOTE_HASH_NONCE, + DOM_SEP__NOTE_NULLIFIER, DOM_SEP__OVSK_M, DOM_SEP__PARTIAL_ADDRESS, + DOM_SEP__PARTIAL_NOTE_VALIDITY_COMMITMENT, DOM_SEP__PRIVATE_FUNCTION_LEAF, + DOM_SEP__PRIVATE_LOG_FIRST_FIELD, DOM_SEP__PRIVATE_TX_HASH, DOM_SEP__PROTOCOL_CONTRACTS, + DOM_SEP__PUBLIC_BYTECODE, DOM_SEP__PUBLIC_CALLDATA, DOM_SEP__PUBLIC_KEYS_HASH, + DOM_SEP__PUBLIC_LEAF_SLOT, DOM_SEP__PUBLIC_STORAGE_MAP_SLOT, DOM_SEP__PUBLIC_TX_HASH, + DOM_SEP__SECRET_HASH, DOM_SEP__SIGNATURE_PAYLOAD, DOM_SEP__SILOED_NOTE_HASH, + DOM_SEP__SILOED_NULLIFIER, DOM_SEP__SYMMETRIC_KEY, DOM_SEP__SYMMETRIC_KEY_2, DOM_SEP__TSK_M, + DOM_SEP__TX_NULLIFIER, DOM_SEP__TX_REQUEST, DOM_SEP__UNIQUE_NOTE_HASH, + NULL_MSG_SENDER_CONTRACT_ADDRESS, SIDE_EFFECT_MASKING_ADDRESS, TX_START_PREFIX, }, hash::poseidon2_hash_bytes, traits::{FromField, ToField}, @@ -129,7 +129,7 @@ impl HashedValueTester::new(); + let mut tester = HashedValueTester::<50, 43>::new(); // ----------------- // Domain separators @@ -174,6 +174,7 @@ fn hashed_values_match_derived() { tester.assert_dom_sep_matches_derived(DOM_SEP__AUTHWIT_NULLIFIER, "authwit_nullifier"); tester.assert_dom_sep_matches_derived(DOM_SEP__SYMMETRIC_KEY, "symmetric_key"); tester.assert_dom_sep_matches_derived(DOM_SEP__SYMMETRIC_KEY_2, "symmetric_key_2"); + tester.assert_dom_sep_matches_derived(DOM_SEP__CIPHERTEXT_FIELD_MASK, "ciphertext_field_mask"); tester.assert_dom_sep_matches_derived( DOM_SEP__PARTIAL_NOTE_VALIDITY_COMMITMENT, "partial_note_validity_commitment", diff --git a/yarn-project/constants/src/constants.gen.ts b/yarn-project/constants/src/constants.gen.ts index 6d63cc576b67..6675dfa2cca1 100644 --- a/yarn-project/constants/src/constants.gen.ts +++ b/yarn-project/constants/src/constants.gen.ts @@ -538,6 +538,7 @@ export enum DomainSeparator { AUTHWIT_NULLIFIER = 1239150694, SYMMETRIC_KEY = 3882206064, SYMMETRIC_KEY_2 = 4129434989, + CIPHERTEXT_FIELD_MASK = 1870492847, PARTIAL_NOTE_VALIDITY_COMMITMENT = 623934423, INITIALIZATION_NULLIFIER = 1653084894, SECRET_HASH = 4199652938,