diff --git a/docs/docs-developers/docs/aztec-nr/testing_contracts.md b/docs/docs-developers/docs/aztec-nr/testing_contracts.md index cc1da22cfe32..80522bc81532 100644 --- a/docs/docs-developers/docs/aztec-nr/testing_contracts.md +++ b/docs/docs-developers/docs/aztec-nr/testing_contracts.md @@ -172,8 +172,8 @@ let total = env.execute_utility(Token::at(token_address).balance_of_private(owne // To set the `msg_sender` the utility function observes, use the `_opts` variant let secret = env.execute_utility_opts( ExecuteUtilityOptions::new().with_from(caller), - Registry::at(registry_address).get_app_siloed_secret(sender, recipient, mode), -); + Registry::at(registry_address).get_app_siloed_secret(sender, recipient), +).map(|secrets| secrets.shared); ``` :::tip Helper function pattern diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index 4a46a11478ce..ae6dcf5eddc3 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -42,8 +42,8 @@ In `TestEnvironment`, use `execute_utility_opts` with the new `ExecuteUtilityOpt ```rust let secret = env.execute_utility_opts( ExecuteUtilityOptions::new().with_from(caller), - Registry::at(registry_address).get_app_siloed_secret(sender, recipient, mode), -); + Registry::at(registry_address).get_app_siloed_secret(sender, recipient), +).map(|secrets| secrets.shared); ``` ### [Prover Node JSON-RPC] Prover API moved to the admin endpoint; `getL2Tips`/`getWorldStateSyncStatus` removed diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/constrained_delivery.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/constrained_delivery.nr index c1f2ffae9496..2311227ae1e7 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/constrained_delivery.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/constrained_delivery.nr @@ -9,6 +9,7 @@ //! See [`constrain_secret`] for how a send is anchored to the registry, use crate::context::PrivateContext; +use crate::messages::delivery::handshake::AppSiloedHandshakeSecrets; use crate::nullifier::utils::compute_nullifier_existence_request; use crate::protocol::{ @@ -20,14 +21,14 @@ use crate::protocol::{ // registry's test suite compares this against its macro-generated `HandshakeRegistry::at(...).method(...).selector` // value so signature drift fails in tests. pub global VALIDATE_HANDSHAKE_SELECTOR: FunctionSelector = - comptime { FunctionSelector::from_signature("validate_handshake((Field),(Field),Field)") }; + comptime { FunctionSelector::from_signature("validate_handshake((Field),(Field),(Field,Field))") }; pub(crate) fn constrain_secret_and_emit_nullifier( context: &mut PrivateContext, registry: AztecAddress, sender: AztecAddress, recipient: AztecAddress, - secret: Field, + secrets: AppSiloedHandshakeSecrets, bootstrapped: bool, index: u32, ) { @@ -36,11 +37,11 @@ pub(crate) fn constrain_secret_and_emit_nullifier( registry, sender, recipient, - secret, + secrets, bootstrapped, index, ); - context.push_nullifier_unsafe(compute_constrained_msg_nullifier(sender, recipient, secret, index)); + context.push_nullifier_unsafe(compute_constrained_msg_nullifier(sender, recipient, secrets, index)); } /// Anchors an untrusted `(secret, index)` to the registry before its constrained tag is emitted. @@ -54,7 +55,7 @@ fn constrain_secret( registry: AztecAddress, sender: AztecAddress, recipient: AztecAddress, - secret: Field, + secrets: AppSiloedHandshakeSecrets, bootstrapped: bool, index: u32, ) { @@ -66,10 +67,10 @@ fn constrain_secret( let _ = context.call_private_function( registry, VALIDATE_HANDSHAKE_SELECTOR, - [sender.to_field(), recipient.to_field(), secret], + [sender.to_field(), recipient.to_field(), secrets.shared, secrets.sender_only], ); } else { - let prev_nullifier = compute_constrained_msg_nullifier(sender, recipient, secret, index - 1); + let prev_nullifier = compute_constrained_msg_nullifier(sender, recipient, secrets, index - 1); context.assert_nullifier_exists(compute_nullifier_existence_request(prev_nullifier, caller)); } } @@ -81,11 +82,11 @@ fn constrain_secret( pub(crate) fn compute_constrained_msg_nullifier( sender: AztecAddress, recipient: AztecAddress, - secret: Field, + secrets: AppSiloedHandshakeSecrets, index: u32, ) -> Field { poseidon2_hash_with_separator( - [sender.to_field(), recipient.to_field(), secret, index as Field], + [sender.to_field(), recipient.to_field(), secrets.shared, secrets.sender_only, index as Field], DOM_SEP__CONSTRAINED_MSG_NULLIFIER, ) } @@ -93,6 +94,7 @@ pub(crate) fn compute_constrained_msg_nullifier( mod test { use crate::context::PrivateContext; use crate::hash::hash_args; + use crate::messages::delivery::handshake::AppSiloedHandshakeSecrets; use crate::protocol::{address::AztecAddress, hash::compute_siloed_nullifier, traits::{FromField, ToField}}; use crate::test::helpers::test_environment::TestEnvironment; use super::{compute_constrained_msg_nullifier, constrain_secret_and_emit_nullifier, VALIDATE_HANDSHAKE_SELECTOR}; @@ -102,13 +104,13 @@ mod test { context: &mut PrivateContext, sender: AztecAddress, recipient: AztecAddress, - secret: Field, + secrets: AppSiloedHandshakeSecrets, index: u32, ) { assert_eq(context.nullifiers.len(), 1); assert_eq( context.nullifiers.get(0).inner.value, - compute_constrained_msg_nullifier(sender, recipient, secret, index), + compute_constrained_msg_nullifier(sender, recipient, secrets, index), ); } @@ -118,13 +120,13 @@ mod test { let registry = AztecAddress::from_field(1); let sender = AztecAddress::from_field(2); let recipient = AztecAddress::from_field(4); - let secret: Field = 1234; + let secrets = AppSiloedHandshakeSecrets { shared: 1234, sender_only: 5678 }; let index: u32 = 0; env.private_context(|context| { - constrain_secret_and_emit_nullifier(context, registry, sender, recipient, secret, true, index); + constrain_secret_and_emit_nullifier(context, registry, sender, recipient, secrets, true, index); - assert_current_nullifier_emitted(context, sender, recipient, secret, index); + assert_current_nullifier_emitted(context, sender, recipient, secrets, index); assert_eq(context.private_call_requests.len(), 0); assert_eq(context.nullifier_read_requests.len(), 0); }); @@ -136,9 +138,10 @@ mod test { let registry = AztecAddress::from_field(1); let sender = AztecAddress::from_field(2); let recipient = AztecAddress::from_field(4); + let secrets = AppSiloedHandshakeSecrets { shared: 1234, sender_only: 5678 }; env.private_context(|context| { - constrain_secret_and_emit_nullifier(context, registry, sender, recipient, 1234, true, 1); + constrain_secret_and_emit_nullifier(context, registry, sender, recipient, secrets, true, 1); }); } @@ -148,7 +151,7 @@ mod test { let registry = AztecAddress::from_field(1); let sender = AztecAddress::from_field(2); let recipient = AztecAddress::from_field(4); - let secret: Field = 1234; + let secrets = AppSiloedHandshakeSecrets { shared: 1234, sender_only: 5678 }; let index: u32 = 0; env.private_context(|context| { @@ -160,9 +163,9 @@ mod test { .returns([child_call_end_counter, empty_returns_hash]) .times(1); - constrain_secret_and_emit_nullifier(context, registry, sender, recipient, secret, false, index); + constrain_secret_and_emit_nullifier(context, registry, sender, recipient, secrets, false, index); - assert_current_nullifier_emitted(context, sender, recipient, secret, index); + assert_current_nullifier_emitted(context, sender, recipient, secrets, index); assert_eq(context.nullifier_read_requests.len(), 0); assert_eq(context.private_call_requests.len(), 1); @@ -171,7 +174,12 @@ mod test { assert_eq(request.call_context.contract_address, registry); assert_eq(request.call_context.function_selector, VALIDATE_HANDSHAKE_SELECTOR); assert(!request.call_context.is_static_call); - assert_eq(request.args_hash, hash_args([sender.to_field(), recipient.to_field(), secret])); + assert_eq( + request.args_hash, + hash_args( + [sender.to_field(), recipient.to_field(), secrets.shared, secrets.sender_only], + ), + ); }); } @@ -181,15 +189,15 @@ mod test { let registry = AztecAddress::from_field(1); let sender = AztecAddress::from_field(2); let recipient = AztecAddress::from_field(4); - let secret: Field = 1234; + let secrets = AppSiloedHandshakeSecrets { shared: 1234, sender_only: 5678 }; let index: u32 = 3; env.private_context(|context| { let _ = OracleMock::mock("aztec_prv_isNullifierPending").returns(false).times(1); - constrain_secret_and_emit_nullifier(context, registry, sender, recipient, secret, false, index); + constrain_secret_and_emit_nullifier(context, registry, sender, recipient, secrets, false, index); - assert_current_nullifier_emitted(context, sender, recipient, secret, index); + assert_current_nullifier_emitted(context, sender, recipient, secrets, index); assert_eq(context.private_call_requests.len(), 0); assert_eq(context.nullifier_read_requests.len(), 1); @@ -199,7 +207,7 @@ mod test { read_request.inner.inner, compute_siloed_nullifier( context.this_address(), - compute_constrained_msg_nullifier(sender, recipient, secret, index - 1), + compute_constrained_msg_nullifier(sender, recipient, secrets, index - 1), ), ); }); diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr index 380d77b0d6b7..f074393830f9 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr @@ -28,6 +28,15 @@ pub struct HandshakePage { pub total_count: u32, } +/// The app-siloed handshake secrets the registry returns for a `(sender, recipient)` pair: the shared secret (used +/// for the discovery tag) and the sender-only secret (folded into the constrained-delivery sequence nullifier so only +/// the sender can advance the sequence). +#[derive(Deserialize, Eq, Serialize)] +pub struct AppSiloedHandshakeSecrets { + pub shared: Field, + pub sender_only: Field, +} + pub(crate) global PROVIDED_SECRETS_ARRAY_BASE_SLOT: Field = sha256_to_field("AZTEC_NR::PROVIDED_SECRETS_ARRAY_BASE_SLOT".as_bytes()); @@ -48,7 +57,7 @@ pub global GET_HANDSHAKES_SELECTOR: FunctionSelector = /// Resolves the secret for `(sender, recipient)`, creating it via the registry's `non_interactive_handshake` /// when no handshake exists yet. /// -/// Returns `(secret, bootstrapped)`, where `bootstrapped` is true when this call created the handshake. +/// Returns `(secrets, bootstrapped)`, where `bootstrapped` is true when this call created the handshake. /// /// ## Batching /// @@ -61,12 +70,12 @@ pub(crate) fn get_or_create_app_siloed_handshake_secret( registry: AztecAddress, sender: AztecAddress, recipient: AztecAddress, -) -> (Field, bool) { +) -> (AppSiloedHandshakeSecrets, bool) { // Safety: the response only selects which path runs. On `None` we bootstrap via `non_interactive_handshake`, // whose constrained return value is the secret, so a forged empty response cannot fabricate one; it can only // trigger an unnecessary re-handshake that replaces the registry note. The caller must constrain the returned - // `(secret, bootstrapped)` pair against the selected tagging index before emitting a handshake-derived tag. - let maybe_secret: Option = unsafe { + // `(secrets, bootstrapped)` pair against the selected tagging index before emitting a handshake-derived tag. + let maybe_secrets: Option = unsafe { let returns = call_utility_function( registry, GET_APP_SILOED_SECRET_SELECTOR, @@ -75,13 +84,13 @@ pub(crate) fn get_or_create_app_siloed_handshake_secret( Deserialize::deserialize(returns) }; - maybe_secret.map(|secret| (secret, false)).unwrap_or_else(|| { + maybe_secrets.map(|secrets| (secrets, false)).unwrap_or_else(|| { // Bootstrap: no handshake exists yet. The registry inserts a fresh note and returns the app-siloed - // secret to the caller. The constrained return is the source of truth for the secret, so no separate + // secrets to the caller. The constrained return is the source of truth, so no separate // `validate_handshake` is needed by constrained delivery. // TODO(F-660): dispatch to `perform_handshake(sender, recipient, handshake_type)` once interactive // handshakes are supported. - let secret: Field = context + let secrets: AppSiloedHandshakeSecrets = context .call_private_function( registry, NON_INTERACTIVE_HANDSHAKE_SELECTOR, @@ -89,7 +98,7 @@ pub(crate) fn get_or_create_app_siloed_handshake_secret( ) .get_preimage(); - (secret, true) + (secrets, true) }) } diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr index 33185be9f38c..07130327c52b 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr @@ -54,7 +54,7 @@ fn derive_handshake_log_tag( recipient: AztecAddress, mode: OnchainDeliveryMode, ) -> Field { - let (secret, bootstrapped) = get_or_create_app_siloed_handshake_secret( + let (secrets, bootstrapped) = get_or_create_app_siloed_handshake_secret( context, STANDARD_HANDSHAKE_REGISTRY_ADDRESS, sender, @@ -62,18 +62,19 @@ fn derive_handshake_log_tag( ); // Safety: the returned index is untrusted and is constrained before the tag is emitted. - let index = unsafe { get_next_tagging_index(secret, mode) }; + let index = unsafe { get_next_tagging_index(secrets.shared, mode) }; constrain_secret_and_emit_nullifier( context, STANDARD_HANDSHAKE_REGISTRY_ADDRESS, sender, recipient, - secret, + secrets, bootstrapped, index, ); - tag_from_secret_and_index(secret, index, mode) + // The discovery tag stays derived from the shared secret only, so the recipient still finds every message. + tag_from_secret_and_index(secrets.shared, index, mode) } fn tag_domain_separator(mode: OnchainDeliveryMode) -> u32 { diff --git a/noir-projects/aztec-nr/aztec/src/standard_addresses.nr b/noir-projects/aztec-nr/aztec/src/standard_addresses.nr index 5665acefb197..a9f0d48aecbe 100644 --- a/noir-projects/aztec-nr/aztec/src/standard_addresses.nr +++ b/noir-projects/aztec-nr/aztec/src/standard_addresses.nr @@ -2,17 +2,17 @@ use protocol_types::{address::AztecAddress, traits::FromField}; pub global STANDARD_AUTH_REGISTRY_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x04fc897b6deff5e3c18900a6a1c8ad601772027a500b8f329083810b7bffaf26, + 0x051808a630f7fdd5a0a2ec1dd49930ce8cec93d8461f29955ff3d94ca66f0651, ); pub global STANDARD_MULTI_CALL_ENTRYPOINT_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x1aaa6153c0d5780188c64dd4d2c28372d42e5bcee5210c4184a312db0fe709d0, + 0x079d9de7111fcddd5744541b717b7f3afe728272af71fe76a9ccab6b3122ce48, ); pub global STANDARD_PUBLIC_CHECKS_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x1da099c6dff135d8e519a4e973f27c0e25cf0897d7cf968c7d38329f46df1056, + 0x0be698e4a821fee5082dfcbbd89e606c68be30de3294a10e82043d3f952d5d51, ); pub global STANDARD_HANDSHAKE_REGISTRY_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x215f91f8907b8d6406a9b209b88b3d8d01c764c81d3704af257a2de6f0cd908d, + 0x1c426f56672754c47197fd2870b087af33d4356a756590a22e47d56ebaf72b1d, ); diff --git a/noir-projects/noir-contracts/contracts/protocol/aztec_sublib/src/standard_addresses.nr b/noir-projects/noir-contracts/contracts/protocol/aztec_sublib/src/standard_addresses.nr index 5665acefb197..a9f0d48aecbe 100644 --- a/noir-projects/noir-contracts/contracts/protocol/aztec_sublib/src/standard_addresses.nr +++ b/noir-projects/noir-contracts/contracts/protocol/aztec_sublib/src/standard_addresses.nr @@ -2,17 +2,17 @@ use protocol_types::{address::AztecAddress, traits::FromField}; pub global STANDARD_AUTH_REGISTRY_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x04fc897b6deff5e3c18900a6a1c8ad601772027a500b8f329083810b7bffaf26, + 0x051808a630f7fdd5a0a2ec1dd49930ce8cec93d8461f29955ff3d94ca66f0651, ); pub global STANDARD_MULTI_CALL_ENTRYPOINT_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x1aaa6153c0d5780188c64dd4d2c28372d42e5bcee5210c4184a312db0fe709d0, + 0x079d9de7111fcddd5744541b717b7f3afe728272af71fe76a9ccab6b3122ce48, ); pub global STANDARD_PUBLIC_CHECKS_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x1da099c6dff135d8e519a4e973f27c0e25cf0897d7cf968c7d38329f46df1056, + 0x0be698e4a821fee5082dfcbbd89e606c68be30de3294a10e82043d3f952d5d51, ); pub global STANDARD_HANDSHAKE_REGISTRY_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x215f91f8907b8d6406a9b209b88b3d8d01c764c81d3704af257a2de6f0cd908d, + 0x1c426f56672754c47197fd2870b087af33d4356a756590a22e47d56ebaf72b1d, ); diff --git a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/handshake_note.nr b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/handshake_note.nr index ded38bc11126..b14695ff3e50 100644 --- a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/handshake_note.nr +++ b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/handshake_note.nr @@ -1,7 +1,14 @@ use aztec::{ keys::ecdh_shared_secret::compute_app_siloed_shared_secret, macros::notes::note, - protocol::{address::AztecAddress, point::EmbeddedCurvePoint, traits::{Deserialize, Packable, Serialize}}, + protocol::{ + address::AztecAddress, + constants::DOM_SEP__CONSTRAINED_MSG_SENDER_SECRET, + hash::poseidon2_hash_with_separator, + point::EmbeddedCurvePoint, + scalar::Scalar, + traits::{Deserialize, Packable, Serialize, ToField}, + }, }; /// A record of a handshake established by the note's owner (the sender). @@ -21,11 +28,22 @@ pub struct HandshakeNote { /// The raw ECDH shared-secret point `S = eph_sk * recipient_address_point`. Only this module can read it for /// siloing, and it is never returned by an external function. secret: EmbeddedCurvePoint, + /// The sender's ephemeral secret key, stored as its two scalar limbs (`EmbeddedCurveScalar` is not `Packable`). + /// Retained so the sender (the note owner) can derive a sender-only secret to fold into constrained-delivery + /// nullifiers; the recipient only ever sees the public `eph_pk`. It leaves the registry only via + /// [`HandshakeNote::nullifier_secret_for`], never unsiloed. + eph_sk_lo: Field, + eph_sk_hi: Field, } impl HandshakeNote { - pub(crate) fn new(shared_secret: EmbeddedCurvePoint, handshake_type: u8, recipient: AztecAddress) -> Self { - Self { handshake_type, recipient, secret: shared_secret } + pub(crate) fn new( + shared_secret: EmbeddedCurvePoint, + handshake_type: u8, + recipient: AztecAddress, + eph_sk: Scalar, + ) -> Self { + Self { handshake_type, recipient, secret: shared_secret, eph_sk_lo: eph_sk.lo, eph_sk_hi: eph_sk.hi } } /// Returns the app-siloed shared secret for `caller`, computed via aztec-nr's canonical @@ -33,4 +51,15 @@ impl HandshakeNote { pub(crate) fn siloed_for(self, caller: AztecAddress) -> Field { compute_app_siloed_shared_secret(self.secret, caller) } + + /// Returns the app-siloed sender-only secret for `caller`, folded into constrained-delivery nullifiers so only + /// the sender (who holds `eph_sk`) can advance a sequence. The recipient cannot derive it: it comes from the + /// secret `eph_sk`, not the public `eph_pk`. Siloing by `caller` keeps it app-specific, mirroring + /// [`HandshakeNote::siloed_for`]. + pub(crate) fn nullifier_secret_for(self, caller: AztecAddress) -> Field { + poseidon2_hash_with_separator( + [self.eph_sk_lo, self.eph_sk_hi, caller.to_field()], + DOM_SEP__CONSTRAINED_MSG_SENDER_SECRET, + ) + } } diff --git a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr index 78ca61ea0e2b..d6232923d586 100644 --- a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr @@ -9,14 +9,15 @@ use sync::handshake_registry_sync; /// signature). See [`HandshakeRegistry::non_interactive_handshake`]. global NON_INTERACTIVE_HANDSHAKE: u8 = 1; -/// Registry for the constrained-delivery shared-secret handshake protocol. +/// Registry for the constrained-delivery handshake protocol. /// /// The registry establishes a master shared-secret point `S` between a sender and a recipient and stores one current -/// note for each `(recipient, sender)` pair. `S` is independent of delivery mode: the recipient should derive per-mode tags +/// note for each `(recipient, sender)` pair. `S` is independent of delivery mode: the recipient should derive per-mode +/// tags /// from it when scanning. The raw `S` never leaves the registry: app contracts call /// [`HandshakeRegistry::non_interactive_handshake`] to receive the secret already siloed to the caller, call /// [`HandshakeRegistry::get_app_siloed_secret`] offchain for an existing handshake, and use -/// [`HandshakeRegistry::validate_handshake`] to check an app-siloed secret against the current stored handshake. The +/// [`HandshakeRegistry::validate_handshake`] to check app-siloed secrets against the current stored handshake. The /// private surfaces silo against `msg_sender()`, so a contract can only obtain or validate secrets siloed to itself. /// Re-handshaking does not revoke already-started constrained-delivery sequences; it only replaces the registry note /// used @@ -30,7 +31,7 @@ pub contract HandshakeRegistry { keys::{ecdh_shared_secret::derive_ecdh_shared_secret, ephemeral::generate_positive_ephemeral_key_pair}, macros::{functions::external, storage::storage}, messages::{ - delivery::{handshake::{HandshakePage, MAX_HANDSHAKES_PER_PAGE}, MessageDelivery}, + delivery::{handshake::{AppSiloedHandshakeSecrets, HandshakePage, MAX_HANDSHAKES_PER_PAGE}, MessageDelivery}, encryption::{aes128::AES128, message_encryption::MessageEncryption}, }, protocol::{ @@ -47,11 +48,13 @@ pub contract HandshakeRegistry { handshakes: Map, Context>, Context>, } - /// Performs a non-interactive handshake from `sender` to `recipient` and returns the app-siloed shared secret + /// Performs a non-interactive handshake from `sender` to `recipient` and returns the [app-siloed handshake + /// secrets][AppSiloedHandshakeSecrets] /// for the calling contract. /// /// The handshake establishes a single shared secret per `(recipient, sender)` pair, independent of delivery - /// mode: the recipient should derive per-mode tags from it when scanning. The handshake note is always stored onchain. + /// mode: the recipient should derive per-mode tags from it when scanning. The handshake note is always stored + /// onchain. /// /// Generates a fresh ephemeral key pair `(eph_sk, eph_pk)`, computes the raw ECDH shared secret point /// `S = eph_sk * recipient_address_point`, and produces three effects: @@ -61,21 +64,22 @@ pub contract HandshakeRegistry { /// recipient discovers handshakes addressed to them by scanning their tag and recovers `S` from `eph_pk` /// via their own ECDH (`recipient_isk * eph_pk`). `eph_pk.y` is fixed positive by the /// [`generate_positive_ephemeral_key_pair`] convention, so only `eph_pk.x` is transmitted. - /// 3. Returns the app-siloed shared secret for `msg_sender()`, allowing the caller to fold "handshake + first - /// tag" into one call without a second hop into the registry. + /// 3. Returns the app-siloed handshake secrets for `msg_sender()`, allowing the caller to fold "handshake + first + /// tag" into one call without a second hop into the registry. The sender-only secret derives from `eph_sk` + /// (retained in the note), so the recipient, who only sees `eph_pk`, cannot reconstruct it. /// /// # Panics /// If `recipient` is not a valid curve point. There are no upstream side effects in this call frame to /// protect, and a fallback would insert a permanent note recording a handshake with an invalid recipient, /// polluting registry state. #[external("private")] - fn non_interactive_handshake(sender: AztecAddress, recipient: AztecAddress) -> Field { + fn non_interactive_handshake(sender: AztecAddress, recipient: AztecAddress) -> AppSiloedHandshakeSecrets { let recipient_point = recipient.to_address_point().expect(f"recipient address is not on the curve"); let (eph_sk, eph_pk) = generate_positive_ephemeral_key_pair(); let s_raw = derive_ecdh_shared_secret(eph_sk, recipient_point.inner); - let note = HandshakeNote::new(s_raw, NON_INTERACTIVE_HANDSHAKE, recipient); + let note = HandshakeNote::new(s_raw, NON_INTERACTIVE_HANDSHAKE, recipient, eph_sk); // The recipient is not involved in this note's delivery: they discover the handshake via the encrypted log // emitted below, not via the sender's note. We deliver onchain unconstrained rather than offchain so the @@ -92,44 +96,57 @@ pub contract HandshakeRegistry { let ciphertext = AES128::encrypt([eph_pk.x], recipient, self.context.this_address()); self.context.emit_private_log_unsafe(log_tag, BoundedVec::from_array(ciphertext)); - note.siloed_for(self.msg_sender()) + let caller = self.msg_sender(); + AppSiloedHandshakeSecrets { shared: note.siloed_for(caller), sender_only: note.nullifier_secret_for(caller) } } - /// Asserts that `app_siloed_secret` is the silo of the stored handshake from `sender` to `recipient`, for - /// the caller (`msg_sender()`). + /// Asserts that `secrets` match the stored handshake from `sender` to `recipient`, for the caller + /// (`msg_sender()`). /// - /// Apps that receive an `app_siloed_secret` from an untrusted source call this once to validate that secret + /// Apps that receive handshake secrets from an untrusted source call this once to validate those secrets /// against the registry's latest stored handshake. /// /// # Panics - /// If no stored handshake for `(sender, recipient)` silos to `app_siloed_secret` under the calling - /// contract's address. + /// If no stored handshake for `(sender, recipient)` silos to the provided secrets under the calling contract's + /// address. #[external("private")] - fn validate_handshake(sender: AztecAddress, recipient: AztecAddress, app_siloed_secret: Field) { + fn validate_handshake(sender: AztecAddress, recipient: AztecAddress, secrets: AppSiloedHandshakeSecrets) { let caller = self.msg_sender(); let replacement_note_message = self.storage.handshakes.at(recipient).at(sender).get_note(); let note = replacement_note_message.get_note(); - assert(note.siloed_for(caller) == app_siloed_secret, "no matching handshake"); + assert(note.siloed_for(caller) == secrets.shared, "no matching handshake"); + assert(note.nullifier_secret_for(caller) == secrets.sender_only, "no matching handshake"); // `PrivateMutable::get_note` proves the current note by nullifying and recreating it. Deliver the // replacement to the sender so later validation calls can prove the same current handshake again. replacement_note_message.deliver(MessageDelivery::onchain_unconstrained()); } - /// Returns the app-siloed shared secret for an existing handshake. + /// Returns the app-siloed secrets for an existing handshake. /// - /// This is the existing-handshake retrieval surface. It returns `silo(S, msg_sender)`, never raw `S`; a different - /// caller receives a different app-siloed value for the same registry note. Siloing by `self.msg_sender()` means - /// a hostile contract cannot read another app's siloed secret: whatever arguments a caller passes, it only ever - /// learns values siloed to its own address. Contracts should still call [HandshakeRegistry::validate_handshake] - /// when they need a constrained proof that a supplied app-siloed secret matches the current handshake. + /// This is the existing-handshake retrieval surface. It returns secrets siloed to `msg_sender`, never raw `S` or + /// `eph_sk`; a different caller receives different app-siloed values for the same registry note. Siloing by + /// `self.msg_sender()` means a hostile contract cannot read another app's siloed secrets: whatever arguments a + /// caller passes, it only ever learns values siloed to its own address. Contracts should still call + /// [HandshakeRegistry::validate_handshake] when they need a constrained proof that supplied app-siloed secrets + /// match the current handshake. #[external("utility")] - unconstrained fn get_app_siloed_secret(sender: AztecAddress, recipient: AztecAddress) -> Option { + unconstrained fn get_app_siloed_secret( + sender: AztecAddress, + recipient: AztecAddress, + ) -> Option { let handshake = self.storage.handshakes.at(recipient).at(sender); if handshake.is_initialized() { - Option::some(handshake.view_note().siloed_for(self.msg_sender())) + let note = handshake.view_note(); + let caller = self.msg_sender(); + Option::some( + AppSiloedHandshakeSecrets { + shared: note.siloed_for(caller), + sender_only: note.nullifier_secret_for(caller), + }, + ) } else { Option::none() } diff --git a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr index 2aa6de1aaeef..d43ba19b3441 100644 --- a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr +++ b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr @@ -4,7 +4,7 @@ use aztec::{ messages::delivery::{ constrained_delivery::VALIDATE_HANDSHAKE_SELECTOR, handshake::{ - GET_APP_SILOED_SECRET_SELECTOR, GET_HANDSHAKES_SELECTOR, MAX_HANDSHAKES_PER_PAGE, + AppSiloedHandshakeSecrets, GET_APP_SILOED_SECRET_SELECTOR, GET_HANDSHAKES_SELECTOR, MAX_HANDSHAKES_PER_PAGE, NON_INTERACTIVE_HANDSHAKE_SELECTOR, }, }, @@ -52,10 +52,11 @@ unconstrained fn selectors_match_the_constrained_delivery_helper() { let registry = HandshakeRegistry::at(AztecAddress::from_field(1)); let sender = AztecAddress::from_field(2); let recipient = AztecAddress::from_field(3); + let secrets = AppSiloedHandshakeSecrets { shared: 0, sender_only: 0 }; assert_eq(registry.get_app_siloed_secret(sender, recipient).selector, GET_APP_SILOED_SECRET_SELECTOR); assert_eq(registry.non_interactive_handshake(sender, recipient).selector, NON_INTERACTIVE_HANDSHAKE_SELECTOR); - assert_eq(registry.validate_handshake(sender, recipient, 0).selector, VALIDATE_HANDSHAKE_SELECTOR); + assert_eq(registry.validate_handshake(sender, recipient, secrets).selector, VALIDATE_HANDSHAKE_SELECTOR); assert_eq(registry.get_handshakes(recipient, 0).selector, GET_HANDSHAKES_SELECTOR); } @@ -64,18 +65,20 @@ unconstrained fn non_interactive_handshake_stores_handshake_for_sender_and_recip let (env, registry_address, sender, _, recipient) = setup(); let registry = HandshakeRegistry::at(registry_address); - let returned_secret = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); + let returned_secrets = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); - let utility_secret = env + let utility_secrets = env .execute_utility_opts( ExecuteUtilityOptions::new().with_from(sender), registry.get_app_siloed_secret(sender, recipient), ) .expect(f"handshake secret should be present"); - assert_eq(utility_secret, returned_secret); - assert(returned_secret != 0, "returned app-siloed secret should be non-zero"); + assert_eq(utility_secrets.shared, returned_secrets.shared); + assert_eq(utility_secrets.sender_only, returned_secrets.sender_only); + assert(returned_secrets.shared != 0, "returned app-siloed secret should be non-zero"); + assert(returned_secrets.sender_only != 0, "returned sender-only secret should be non-zero"); - env.call_private(sender, registry.validate_handshake(sender, recipient, returned_secret)); + env.call_private(sender, registry.validate_handshake(sender, recipient, returned_secrets)); } // The DH-direct flow lifts `recipient` to a curve point and fails loud if the address has no @@ -131,11 +134,11 @@ unconstrained fn rehandshake_replaces_previous_secret_and_returns_latest() { let (env, registry_address, sender, _, recipient) = setup(); let registry = HandshakeRegistry::at(registry_address); - let secret_first = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); + let secrets_first = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); let logs_first = txe_oracles::get_last_tx_effects().private_logs; assert_eq(logs_first.len(), 2); - let secret_second = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); + let secrets_second = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); let logs_second = txe_oracles::get_last_tx_effects().private_logs; assert_eq(logs_second.len(), 2); @@ -155,7 +158,10 @@ unconstrained fn rehandshake_replaces_previous_secret_and_returns_latest() { assert(logs_first.get(1).get(1) != logs_second.get(1).get(1), "ciphertexts should differ across handshakes"); // Same caller and same `(sender, recipient)`, but two distinct raw `S` produce two distinct silos - assert(secret_first != secret_second, "successive handshakes should produce distinct app-siloed secrets"); + assert( + secrets_first.shared != secrets_second.shared, + "successive handshakes should produce distinct app-siloed secrets", + ); let retrieved = env .execute_utility_opts( @@ -163,9 +169,10 @@ unconstrained fn rehandshake_replaces_previous_secret_and_returns_latest() { registry.get_app_siloed_secret(sender, recipient), ) .expect(f"handshake secret should be present"); - assert_eq(retrieved, secret_second); + assert_eq(retrieved.shared, secrets_second.shared); + assert_eq(retrieved.sender_only, secrets_second.sender_only); - env.call_private(sender, registry.validate_handshake(sender, recipient, secret_second)); + env.call_private(sender, registry.validate_handshake(sender, recipient, secrets_second)); } #[test(should_fail_with = "no matching handshake")] @@ -173,10 +180,10 @@ unconstrained fn rehandshake_revokes_previous_secret() { let (env, registry_address, sender, _, recipient) = setup(); let registry = HandshakeRegistry::at(registry_address); - let secret_first = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); + let secrets_first = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); let _ = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); - env.call_private(sender, registry.validate_handshake(sender, recipient, secret_first)); + env.call_private(sender, registry.validate_handshake(sender, recipient, secrets_first)); } #[test] @@ -210,10 +217,10 @@ unconstrained fn one_sender_can_have_active_handshakes_with_many_recipients() { let (env, registry_address, sender, recipient_a, recipient_b) = setup_with_two_recipients(); let registry = HandshakeRegistry::at(registry_address); - let secret_a = env.call_private(sender, registry.non_interactive_handshake(sender, recipient_a)); - let secret_b = env.call_private(sender, registry.non_interactive_handshake(sender, recipient_b)); + let secrets_a = env.call_private(sender, registry.non_interactive_handshake(sender, recipient_a)); + let secrets_b = env.call_private(sender, registry.non_interactive_handshake(sender, recipient_b)); - assert(secret_a != secret_b, "separate recipient handshakes should produce distinct secrets"); + assert(secrets_a.shared != secrets_b.shared, "separate recipient handshakes should produce distinct secrets"); let retrieved_a = env .execute_utility_opts( @@ -228,11 +235,13 @@ unconstrained fn one_sender_can_have_active_handshakes_with_many_recipients() { ) .expect(f"handshake secret should be present for recipient B"); - assert_eq(retrieved_a, secret_a); - assert_eq(retrieved_b, secret_b); + assert_eq(retrieved_a.shared, secrets_a.shared); + assert_eq(retrieved_a.sender_only, secrets_a.sender_only); + assert_eq(retrieved_b.shared, secrets_b.shared); + assert_eq(retrieved_b.sender_only, secrets_b.sender_only); - env.call_private(sender, registry.validate_handshake(sender, recipient_a, secret_a)); - env.call_private(sender, registry.validate_handshake(sender, recipient_b, secret_b)); + env.call_private(sender, registry.validate_handshake(sender, recipient_a, secrets_a)); + env.call_private(sender, registry.validate_handshake(sender, recipient_b, secrets_b)); } #[test] @@ -270,13 +279,13 @@ unconstrained fn validate_handshake_rejects_wrong_sender() { let (env, registry_address, sender, other_sender, recipient) = setup(); let registry = HandshakeRegistry::at(registry_address); - let secret = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); + let secrets = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); let _ = env.call_private(other_sender, registry.non_interactive_handshake(other_sender, recipient)); env.call_private_opts( sender, CallPrivateOptions::new().with_additional_scopes([other_sender]), - registry.validate_handshake(other_sender, recipient, secret), + registry.validate_handshake(other_sender, recipient, secrets), ); } @@ -285,10 +294,10 @@ unconstrained fn validate_handshake_rejects_wrong_recipient() { let (env, registry_address, sender, recipient_a, recipient_b) = setup_with_two_recipients(); let registry = HandshakeRegistry::at(registry_address); - let secret = env.call_private(sender, registry.non_interactive_handshake(sender, recipient_a)); + let secrets = env.call_private(sender, registry.non_interactive_handshake(sender, recipient_a)); let _ = env.call_private(sender, registry.non_interactive_handshake(sender, recipient_b)); - env.call_private(sender, registry.validate_handshake(sender, recipient_b, secret)); + env.call_private(sender, registry.validate_handshake(sender, recipient_b, secrets)); } // `PrivateMutable`'s init nullifier is derived from the owner's nullifier @@ -322,15 +331,22 @@ unconstrained fn get_app_siloed_secret_differs_per_msg_sender() { let (env, registry_address, sender, other_sender, recipient) = setup(); let registry = HandshakeRegistry::at(registry_address); - let sender_secret = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); - let other_sender_secret = env + let sender_secrets = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); + let other_sender_secrets = env .execute_utility_opts( ExecuteUtilityOptions::new().with_from(other_sender), registry.get_app_siloed_secret(sender, recipient), ) .expect(f"handshake secret should be present for other caller"); - assert(sender_secret != other_sender_secret, "different callers should receive different siloed secrets"); + assert( + sender_secrets.shared != other_sender_secrets.shared, + "different callers should receive different siloed secrets", + ); + assert( + sender_secrets.sender_only != other_sender_secrets.sender_only, + "different callers should receive different sender-only secrets", + ); } #[test] @@ -339,7 +355,7 @@ unconstrained fn validate_handshake_accepts_secret_siloed_for_msg_sender() { let registry = HandshakeRegistry::at(registry_address); let _ = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); - let other_sender_secret = env + let other_sender_secrets = env .execute_utility_opts( ExecuteUtilityOptions::new().with_from(other_sender), registry.get_app_siloed_secret(sender, recipient), @@ -351,7 +367,7 @@ unconstrained fn validate_handshake_accepts_secret_siloed_for_msg_sender() { env.call_private_opts( other_sender, CallPrivateOptions::new().with_additional_scopes([sender]), - registry.validate_handshake(sender, recipient, other_sender_secret), + registry.validate_handshake(sender, recipient, other_sender_secrets), ); } @@ -360,12 +376,12 @@ unconstrained fn validate_handshake_rejects_secret_siloed_for_different_msg_send let (env, registry_address, sender, other_sender, recipient) = setup(); let registry = HandshakeRegistry::at(registry_address); - let sender_secret = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); + let sender_secrets = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); env.call_private_opts( other_sender, CallPrivateOptions::new().with_additional_scopes([sender]), - registry.validate_handshake(sender, recipient, sender_secret), + registry.validate_handshake(sender, recipient, sender_secrets), ); } @@ -376,9 +392,33 @@ unconstrained fn validate_handshake_rejects_wrong_secret() { let (env, registry_address, sender, _, recipient) = setup(); let registry = HandshakeRegistry::at(registry_address); - let secret = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); + let secrets = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); - env.call_private(sender, registry.validate_handshake(sender, recipient, secret + 1)); + env.call_private( + sender, + registry.validate_handshake( + sender, + recipient, + AppSiloedHandshakeSecrets { shared: secrets.shared + 1, sender_only: secrets.sender_only }, + ), + ); +} + +#[test(should_fail_with = "no matching handshake")] +unconstrained fn validate_handshake_rejects_wrong_sender_only_secret() { + let (env, registry_address, sender, _, recipient) = setup(); + let registry = HandshakeRegistry::at(registry_address); + + let secrets = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); + + env.call_private( + sender, + registry.validate_handshake( + sender, + recipient, + AppSiloedHandshakeSecrets { shared: secrets.shared, sender_only: secrets.sender_only + 1 }, + ), + ); } #[test(should_fail_with = "Failed to get a note")] @@ -386,7 +426,10 @@ unconstrained fn validate_handshake_panics_when_no_handshake() { let (env, registry_address, sender, _, recipient) = setup(); let registry = HandshakeRegistry::at(registry_address); - env.call_private(sender, registry.validate_handshake(sender, recipient, 1)); + env.call_private( + sender, + registry.validate_handshake(sender, recipient, AppSiloedHandshakeSecrets { shared: 1, sender_only: 1 }), + ); } #[test] @@ -394,7 +437,7 @@ unconstrained fn non_interactive_handshake_is_discovered_by_recipient() { let (env, registry_address, sender, _, recipient) = setup(); let registry = HandshakeRegistry::at(registry_address); - let returned_secret = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); + let returned_secrets = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); let discovered = env.execute_utility(registry.get_handshakes(recipient, 0)); @@ -404,16 +447,17 @@ unconstrained fn non_interactive_handshake_is_discovered_by_recipient() { // The recipient derives the same app-siloed secret from the discovered eph_pk via ECDH. let handshake = discovered.items.get(0); let recipient_secret = env.utility_context_at(sender, |_| get_shared_secret(recipient, handshake.eph_pk, sender)); - assert_eq(recipient_secret, returned_secret); + assert_eq(recipient_secret, returned_secrets.shared); // The custom sync hook falls through to `do_sync_state`, so default note discovery still works. - let utility_secret = env + let utility_secrets = env .execute_utility_opts( ExecuteUtilityOptions::new().with_from(sender), registry.get_app_siloed_secret(sender, recipient), ) .expect(f"handshake secret should be present"); - assert_eq(utility_secret, returned_secret); + assert_eq(utility_secrets.shared, returned_secrets.shared); + assert_eq(utility_secrets.sender_only, returned_secrets.sender_only); } #[test] diff --git a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr index d17950c89343..95fe315a991b 100644 --- a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr @@ -12,8 +12,9 @@ pub contract ConstrainedDeliveryTest { oracle::notes::get_next_tagging_index, protocol::{ address::AztecAddress, - constants::DOM_SEP__CONSTRAINED_MSG_LOG_TAG, - hash::{compute_log_tag, poseidon2_hash}, + constants::{DOM_SEP__CONSTRAINED_MSG_LOG_TAG, DOM_SEP__CONSTRAINED_MSG_NULLIFIER}, + hash::{compute_log_tag, poseidon2_hash, poseidon2_hash_with_separator}, + traits::ToField, }, standard_addresses::STANDARD_HANDSHAKE_REGISTRY_ADDRESS, state_vars::{Owned, PrivateSet}, @@ -67,7 +68,7 @@ pub contract ConstrainedDeliveryTest { #[external("utility")] unconstrained fn get_app_siloed_secret(sender: AztecAddress, recipient: AztecAddress) -> Option { let registry = HandshakeRegistry::at(STANDARD_HANDSHAKE_REGISTRY_ADDRESS); - self.call(registry.get_app_siloed_secret(sender, recipient)) + self.call(registry.get_app_siloed_secret(sender, recipient)).map(|secrets| secrets.shared) } /// Returns the values of all `FieldNote`s discovered for `owner`. The e2e test derives both the count and the @@ -88,4 +89,17 @@ pub contract ConstrainedDeliveryTest { ); self.context.emit_private_log_unsafe(log_tag, BoundedVec::from_array([secret])); } + + /// Test-only: models a malicious party that knows only the shared secret emitting the constrained sequence + /// nullifier for `(sender, recipient, secret, index)`. This is exactly the value a recipient can compute, since + /// the shared secret is recoverable from the handshake's public ephemeral key. If the sequence nullifier is + /// bound to a sender-only secret the sender's real nullifier prevents griefing the sequence. + #[external("private")] + fn grief_emit_constrained_nullifier(sender: AztecAddress, recipient: AztecAddress, secret: Field, index: u32) { + let nullifier = poseidon2_hash_with_separator( + [sender.to_field(), recipient.to_field(), secret, index as Field], + DOM_SEP__CONSTRAINED_MSG_NULLIFIER, + ); + self.context.push_nullifier_unsafe(nullifier); + } } diff --git a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr index b423f116d042..b58e762a2340 100644 --- a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr +++ b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr @@ -47,7 +47,8 @@ unconstrained fn rehandshake_replaces_registry_secret_for_future_delivery() { ExecuteUtilityOptions::new().with_from(test_address), registry.get_app_siloed_secret(sender, recipient), ) - .expect(f"first delivery should have stored a handshake"); + .expect(f"first delivery should have stored a handshake") + .shared; let _ = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); let second_secret = env @@ -55,7 +56,8 @@ unconstrained fn rehandshake_replaces_registry_secret_for_future_delivery() { ExecuteUtilityOptions::new().with_from(test_address), registry.get_app_siloed_secret(sender, recipient), ) - .expect(f"re-handshake should have stored a replacement handshake"); + .expect(f"re-handshake should have stored a replacement handshake") + .shared; assert(first_secret != second_secret, "re-handshake should produce a distinct secret"); env.call_private_opts(sender, authorizing(registry_address), test_contract.emit_event(recipient, 1)); @@ -76,9 +78,43 @@ unconstrained fn fails_at_index_above_zero_without_prior_nullifier() { ExecuteUtilityOptions::new().with_from(test_address), registry.get_app_siloed_secret(sender, recipient), ) - .expect(f"handshake should be siloed for the test contract"); + .expect(f"handshake should be siloed for the test contract") + .shared; env.call_private(sender, test_contract.emit_constrained_log_without_nullifier(secret, 0)); let _ = env.call_private_opts(sender, authorizing(registry_address), test_contract.emit_event(recipient, 1)); } + +/// A malicious recipient that knows the shared secret pre-emits the sender's next constrained nullifier from the same +/// app contract. The shared secret is recoverable by the recipient from the handshake's public ephemeral key, so the +/// **recipient** can compute the **sender's** next sequence nullifier and emit it first. Until the nullifier is bound +/// to a sender-only secret this griefs the sequence: the sender's next send will on the duplicate nullifier. +/// The test asserts the sender's second send succeeds. +#[test] +unconstrained fn malicious_recipient_cannot_grief_sender_sequence() { + let (env, registry_address, test_address, sender, recipient) = setup(); + let test_contract = ConstrainedDeliveryTest::at(test_address); + let registry = HandshakeRegistry::at(registry_address); + + // Honest sender's first constrained send: bootstraps the handshake and emits the index-0 nullifier. + env.call_private_opts(sender, authorizing(registry_address), test_contract.emit_event(recipient, 1)); + + // The recipient can reconstruct this app-siloed shared secret from the handshake's public ephemeral key. + let shared_secret = env + .execute_utility_opts( + ExecuteUtilityOptions::new().with_from(test_address), + registry.get_app_siloed_secret(sender, recipient), + ) + .expect(f"first delivery should have stored a handshake") + .shared; + + // The recipient pre-emits the sender's next (indqex 1) nullifier from the same app contract. The nullifier value + // depends only on the explicit args and its siloing on the app contract, not on the signer, so any account could + // sign this; we sign from the `recipient` to model the actual attacker. + env.call_private(recipient, test_contract.grief_emit_constrained_nullifier(sender, recipient, shared_secret, 1)); + + // The sender's next constrained send wants index 1. Without sender-only siloing of the nullifier + // this call's nullifier would collides with the grief and revert. + env.call_private_opts(sender, authorizing(registry_address), test_contract.emit_event(recipient, 2)); +} diff --git a/noir-projects/noir-contracts/pinned-standard-contracts.tar.gz b/noir-projects/noir-contracts/pinned-standard-contracts.tar.gz index 29e560a371b1..31ad455ae2fb 100644 Binary files a/noir-projects/noir-contracts/pinned-standard-contracts.tar.gz and b/noir-projects/noir-contracts/pinned-standard-contracts.tar.gz differ 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 d5143af64c42..9f0de288e38d 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr @@ -732,6 +732,8 @@ pub global DOM_SEP__UNCONSTRAINED_MSG_LOG_TAG: u32 = 1485357192; pub global DOM_SEP__CONSTRAINED_MSG_LOG_TAG: u32 = 3715244738; /// Domain separator for nullifiers used during constrained delivery. pub global DOM_SEP__CONSTRAINED_MSG_NULLIFIER: u32 = 3723577546; +/// Domain separator for the sender-only secret folded into constrained-delivery nullifiers. +pub global DOM_SEP__CONSTRAINED_MSG_SENDER_SECRET: u32 = 1182889476; /// Domain separator for non-interactive handshake log tags emitted by the handshake registry contract. Used by /// [`crate::hash::compute_log_tag`]. pub global DOM_SEP__NON_INTERACTIVE_HANDSHAKE_LOG_TAG: u32 = 4046403018; 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 92e7295289d3..51cbf396fe68 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 @@ -10,11 +10,11 @@ use crate::{ DOM_SEP__AUTHWIT_OUTER, DOM_SEP__BLOB_CHALLENGE_Z, DOM_SEP__BLOB_GAMMA_ACC, DOM_SEP__BLOB_GAMMA_FINAL, DOM_SEP__BLOB_HASHED_Y_LIMBS, DOM_SEP__BLOB_Z_ACC, DOM_SEP__BLOCK_HEADER_HASH, DOM_SEP__BLOCK_HEADERS_HASH, DOM_SEP__CONSTRAINED_MSG_LOG_TAG, - DOM_SEP__CONSTRAINED_MSG_NULLIFIER, DOM_SEP__CONTRACT_ADDRESS_V2, - DOM_SEP__CONTRACT_CLASS_ID, DOM_SEP__ECDH_FIELD_MASK, DOM_SEP__ECDH_SUBKEY, - DOM_SEP__EVENT_COMMITMENT, DOM_SEP__EVENT_LOG_TAG, DOM_SEP__FUNCTION_ARGS, - DOM_SEP__INITIALIZATION_NULLIFIER, DOM_SEP__INITIALIZER, DOM_SEP__IVSK_M, - DOM_SEP__MERKLE_HASH, DOM_SEP__MESSAGE_NULLIFIER, DOM_SEP__NHK_M, + DOM_SEP__CONSTRAINED_MSG_NULLIFIER, DOM_SEP__CONSTRAINED_MSG_SENDER_SECRET, + DOM_SEP__CONTRACT_ADDRESS_V2, DOM_SEP__CONTRACT_CLASS_ID, DOM_SEP__ECDH_FIELD_MASK, + DOM_SEP__ECDH_SUBKEY, DOM_SEP__EVENT_COMMITMENT, DOM_SEP__EVENT_LOG_TAG, + DOM_SEP__FUNCTION_ARGS, DOM_SEP__INITIALIZATION_NULLIFIER, DOM_SEP__INITIALIZER, + DOM_SEP__IVSK_M, DOM_SEP__MERKLE_HASH, DOM_SEP__MESSAGE_NULLIFIER, DOM_SEP__NHK_M, DOM_SEP__NON_INTERACTIVE_HANDSHAKE_LOG_TAG, DOM_SEP__NOTE_COMPLETION_LOG_TAG, DOM_SEP__NOTE_HASH, DOM_SEP__NOTE_HASH_NONCE, DOM_SEP__NOTE_NULLIFIER, DOM_SEP__NULLIFIER_MERKLE, DOM_SEP__OVSK_M, DOM_SEP__PARTIAL_ADDRESS, @@ -144,7 +144,7 @@ impl HashedValueTester::new(); + let mut tester = HashedValueTester::<74, 67>::new(); // ----------------- // Domain separators @@ -185,6 +185,10 @@ fn hashed_values_match_derived() { DOM_SEP__CONSTRAINED_MSG_NULLIFIER, "constrained_msg_nullifier", ); + tester.assert_dom_sep_matches_derived( + DOM_SEP__CONSTRAINED_MSG_SENDER_SECRET, + "constrained_msg_sender_secret", + ); tester.assert_dom_sep_matches_derived( DOM_SEP__NON_INTERACTIVE_HANDSHAKE_LOG_TAG, "non_interactive_handshake_log_tag", diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts index 0a0f9778ebcf..9cdb6b03660f 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts @@ -1,3 +1,4 @@ +import { BackendType, Barretenberg } from '@aztec/bb.js'; import { BlockNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Grumpkin } from '@aztec/foundation/crypto/grumpkin'; import { Fr } from '@aztec/foundation/curves/bn254'; @@ -89,6 +90,14 @@ describe('Utility Execution test suite', () => { let anchorBlockHeader: BlockHeader; const ownerSecretKey = Fr.fromHexString('2dcc5485a58316776299be08c78fa3788a1a7961ae30dc747fb1be17692a8d32'); + beforeAll(async () => { + await Barretenberg.initSingleton({ backend: BackendType.Wasm, skipSrsInit: true, threads: 1 }); + }); + + afterAll(async () => { + await Barretenberg.destroySingleton(); + }); + const buildNote = (amount: bigint) => { return new Note([new Fr(amount)]); }; diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts index 578e14136fc2..64a0314f0423 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts @@ -1012,8 +1012,13 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra } } -const STANDARD_HANDSHAKE_REGISTRY_GET_HANDSHAKES_SIGNATURE = 'get_handshakes((Field),u32)'; -const STANDARD_HANDSHAKE_REGISTRY_GET_APP_SILOED_SECRET_SIGNATURE = 'get_app_siloed_secret((Field),(Field))'; +// Registry reads any contract may make without an authorizeUtilityCall hook: the constrained-delivery library +// issues these implicitly on behalf of the calling app. All are safe to default-authorize because the registry +// siloes every returned secret to `msg_sender`, so a caller only ever learns values siloed to its own address. +const STANDARD_HANDSHAKE_REGISTRY_DEFAULT_AUTHORIZED_READ_SIGNATURES = [ + 'get_handshakes((Field),u32)', + 'get_app_siloed_secret((Field),(Field))', +]; async function doesSelectorHaveSignature(functionSelector: FunctionSelector, signature: string): Promise { return functionSelector.equals(await FunctionSelector.fromSignature(signature)); @@ -1027,9 +1032,10 @@ async function isStandardHandshakeRegistryUtilityRead( return false; } - const [isGetHandshakes, isGetAppSiloedSecret] = await Promise.all([ - doesSelectorHaveSignature(functionSelector, STANDARD_HANDSHAKE_REGISTRY_GET_HANDSHAKES_SIGNATURE), - doesSelectorHaveSignature(functionSelector, STANDARD_HANDSHAKE_REGISTRY_GET_APP_SILOED_SECRET_SIGNATURE), - ]); - return isGetHandshakes || isGetAppSiloedSecret; + const matches = await Promise.all( + STANDARD_HANDSHAKE_REGISTRY_DEFAULT_AUTHORIZED_READ_SIGNATURES.map(signature => + doesSelectorHaveSignature(functionSelector, signature), + ), + ); + return matches.some(Boolean); } diff --git a/yarn-project/standard-contracts/src/standard_contract_data.ts b/yarn-project/standard-contracts/src/standard_contract_data.ts index d6f611b133db..e8a67a98478d 100644 --- a/yarn-project/standard-contracts/src/standard_contract_data.ts +++ b/yarn-project/standard-contracts/src/standard_contract_data.ts @@ -20,21 +20,21 @@ export const StandardContractSalt: Record = { }; export const StandardContractAddress: Record = { - AuthRegistry: AztecAddress.fromStringUnsafe('0x04fc897b6deff5e3c18900a6a1c8ad601772027a500b8f329083810b7bffaf26'), + AuthRegistry: AztecAddress.fromStringUnsafe('0x051808a630f7fdd5a0a2ec1dd49930ce8cec93d8461f29955ff3d94ca66f0651'), MultiCallEntrypoint: AztecAddress.fromStringUnsafe( - '0x1aaa6153c0d5780188c64dd4d2c28372d42e5bcee5210c4184a312db0fe709d0', + '0x079d9de7111fcddd5744541b717b7f3afe728272af71fe76a9ccab6b3122ce48', ), - PublicChecks: AztecAddress.fromStringUnsafe('0x1da099c6dff135d8e519a4e973f27c0e25cf0897d7cf968c7d38329f46df1056'), + PublicChecks: AztecAddress.fromStringUnsafe('0x0be698e4a821fee5082dfcbbd89e606c68be30de3294a10e82043d3f952d5d51'), HandshakeRegistry: AztecAddress.fromStringUnsafe( - '0x215f91f8907b8d6406a9b209b88b3d8d01c764c81d3704af257a2de6f0cd908d', + '0x1c426f56672754c47197fd2870b087af33d4356a756590a22e47d56ebaf72b1d', ), }; export const StandardContractClassId: Record = { - AuthRegistry: Fr.fromString('0x1034dc7a2f6e79bc30624e31aa1bcbfbf7771dcb18b1ce15996bf2ec8486ed34'), - MultiCallEntrypoint: Fr.fromString('0x0c631fbcc97778edadd2cc20bc42a7f849a1933d363d93106a3a655856286f31'), - PublicChecks: Fr.fromString('0x22c35afeea9f8bfc36b0fc9ba78bdd92769fd42f3926822cf85d4b0596e00999'), - HandshakeRegistry: Fr.fromString('0x278dcc5acdb19e5a7f0ae9b36c6a79bf25215a3e1bcad54caa47179a24d40289'), + AuthRegistry: Fr.fromString('0x17d4b33382b5bd68dc10bfe3f14f8fe2d93747a52e695107187c0890f9c50fd6'), + MultiCallEntrypoint: Fr.fromString('0x1592122f743a58c2836eb2c801bcd87b27ed4cab239feceeef6c1721bf24d81f'), + PublicChecks: Fr.fromString('0x06a0dceff508760584b3001324e0cdd6d8c41384100177365824c9b4e562e47b'), + HandshakeRegistry: Fr.fromString('0x22f67586e2144bf56dbe53cb1eb02127ad09a3234ab1a37020fdc967763ec87d'), }; export const StandardContractClassIdPreimage: Record< @@ -42,23 +42,23 @@ export const StandardContractClassIdPreimage: Record< { artifactHash: Fr; privateFunctionsRoot: Fr; publicBytecodeCommitment: Fr } > = { AuthRegistry: { - artifactHash: Fr.fromString('0x05c4f994f0dd2b9e03b57781ec0d651a79cc657a24786673a757c76d62f31598'), + artifactHash: Fr.fromString('0x0729f21c5bd948cc4da3bb3c60645a1839b0b6c00a5c5cd417fa86872aa49d6c'), privateFunctionsRoot: Fr.fromString('0x17b584350f4c3ccafd8f688729afb9feab8976114fb40012e9dee65022c072a4'), publicBytecodeCommitment: Fr.fromString('0x2545f39893766508ce37bb5cea5e4dcab04c6f7f79f3089b1c076876e9d268b2'), }, MultiCallEntrypoint: { - artifactHash: Fr.fromString('0x2bb5473f1f2f29e39e85439dff7e5dbf727c4d840640c21a6009bf25626e0dae'), + artifactHash: Fr.fromString('0x24ea3a7d06408c72aeef62be899630833cf16708b3425719f864525034fe99f5'), privateFunctionsRoot: Fr.fromString('0x0e68dfbb256e80b08b3aef47aca1f2669e97a9c6259787893c1223ac083ad5d5'), publicBytecodeCommitment: Fr.fromString('0x0ce4c618c3ed7f3a20410e618c06bb701e150af7fe28a3e92f68e7733809f33e'), }, PublicChecks: { - artifactHash: Fr.fromString('0x2c439427c5bde0db108e859d0a0602f1feffae12e7250193b5ffe0a9442a9790'), + artifactHash: Fr.fromString('0x03e6afeb62375814bf4ced5b8c998195d9d7175310b8c6f2a537f542ba2ed301'), privateFunctionsRoot: Fr.fromString('0x202860adb1b8975971eeaf571aaaa88a27f4035290d58532ae7d60b0dfaad54c'), publicBytecodeCommitment: Fr.fromString('0x013c4f854a5c87c9daf86c5f9bc07a42c2a061f1d924a5b3564ec7edc8e18cb7'), }, HandshakeRegistry: { - artifactHash: Fr.fromString('0x006e70d1d4d34b07e89bf4aab8a59a4d7e66d469cae4164723d63e66828be903'), - privateFunctionsRoot: Fr.fromString('0x2e839c3fda7214a7ba230e564fe13c9bfed033132e731bf223321485f0b8068c'), + artifactHash: Fr.fromString('0x08c1232c3c94ccfb70a6eb4c03fb639dce0627ce4799c660f8a9a264f388127a'), + privateFunctionsRoot: Fr.fromString('0x2863ed9f39f6ac8c8d66b236744dd3d41aa699a2b453b24bf0f03701839b5859'), publicBytecodeCommitment: Fr.fromString('0x0ce4c618c3ed7f3a20410e618c06bb701e150af7fe28a3e92f68e7733809f33e'), }, }; @@ -94,15 +94,15 @@ export const StandardContractPrivateFunctions: Record< HandshakeRegistry: [ { selector: FunctionSelector.fromField( - Fr.fromString('0x00000000000000000000000000000000000000000000000000000000d12ace81'), + Fr.fromString('0x00000000000000000000000000000000000000000000000000000000db548fcf'), ), - vkHash: Fr.fromString('0x2bf48dfeb80efd1b12ca08992ed1f900938764b1cca4afb50aad54096485e7dc'), + vkHash: Fr.fromString('0x1945c32345be651c37ad424f1ddb7eaf88703521d8aadebb0805b0383fe3aeac'), }, { selector: FunctionSelector.fromField( - Fr.fromString('0x00000000000000000000000000000000000000000000000000000000db548fcf'), + Fr.fromString('0x00000000000000000000000000000000000000000000000000000000f1ff839b'), ), - vkHash: Fr.fromString('0x0eaa1eff3977b3636573dd23f2e32196b7b0f1b13b38d98e6c3c9b5774c40668'), + vkHash: Fr.fromString('0x1b01579dadd8a590ba9ad78b2afb9f485b1fb23125a732e06f8d15902267949b'), }, ], };