Skip to content

Commit 6696135

Browse files
authored
chore: backport handshake registry contract (#22854) to v4-next (#23063)
Cherry-picks #22854 (`feat(aztec-nr): Initial handshake registry contract with non interactive handshake function`) to `backport-to-v4-next-staging`. ## Conflict resolution The only conflict was in `noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr`. `next` has accumulated a number of new merkle/partial-note dom seps (`DOM_SEP__MERKLE_HASH`, `DOM_SEP__NULLIFIER_MERKLE`, `DOM_SEP__PARTIAL_NOTE_COMMITMENT`, `DOM_SEP__PUBLIC_DATA_MERKLE`) that don't exist on `v4-next`, so the upstream version of this file imports symbols that aren't defined here. Resolution: drop the imports/assertions for those non-existent constants, but keep the two new ones added by this PR (`DOM_SEP__HANDSHAKE_SECRET_HASH`, `DOM_SEP__NON_INTERACTIVE_HANDSHAKE_LOG_TAG`). `HashedValueTester` sized `<58, 51>` = v4-next baseline `<56, 49>` plus the two new dom seps. Validated: - Imports list matches the symbols actually referenced inside `hashed_values_match_derived` (no orphan imports / no missing imports). - Assertion counts inside the test match the tester generics: `48` `assert_dom_sep_matches_derived` + `3` `assert_blob_prefixes_match_derived` = 51 u32 values; plus `1` protocol-circuit value + `6` aztec-nr values = 58 total field values. - All aztec-nr APIs the new contract uses (`derive_ecdh_shared_secret`, `generate_positive_ephemeral_key_pair`, `set_sender_for_tags`, `compute_log_tag`, `MessageDelivery.ONCHAIN_UNCONSTRAINED`, `Owned`, `emit_private_log_vec_unsafe`, `to_address_point`, `compute_siloed_private_log_first_field`, `poseidon2_hash_with_separator`) exist on `v4-next` — the contract uses the `aztec::protocol` re-export of `protocol_types`. ## Commit structure Per the backport convention, history is preserved as: 1. Cherry-pick with conflicts (markers in tree, recorded in history). 2. Conflict resolution commit (this PR's tip). No separate build-fixes commit was needed — the conflict was metadata-only. ## Build verification The container does not ship a prebuilt `nargo`, and Docker is unavailable, so `noir-contracts/bootstrap.sh` cannot run locally. Relying on CI for end-to-end build validation. Original PR: #22854 ClaudeBox log: https://claudebox.work/s/096af9ddd4f770c8?run=4
2 parents ea0dc31 + f0adb16 commit 6696135

8 files changed

Lines changed: 428 additions & 3 deletions

File tree

noir-projects/noir-contracts/Nargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ members = [
2626
"contracts/app/simple_token_contract",
2727
"contracts/fees/fpc_contract",
2828
"contracts/fees/sponsored_fpc_contract",
29+
"contracts/message_discovery/handshake_registry_contract",
2930
"contracts/protocol/auth_registry_contract",
3031
"contracts/protocol/contract_class_registry_contract",
3132
"contracts/protocol/contract_instance_registry_contract",
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[package]
2+
name = "handshake_registry_contract"
3+
authors = [""]
4+
compiler_version = ">=0.25.0"
5+
type = "contract"
6+
7+
[dependencies]
8+
aztec = { path = "../../../../aztec-nr/aztec" }
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use aztec::{
2+
macros::notes::note,
3+
protocol::{
4+
address::AztecAddress,
5+
constants::DOM_SEP__HANDSHAKE_SECRET_HASH,
6+
hash::poseidon2_hash_with_separator,
7+
point::Point,
8+
traits::{Deserialize, Packable, Serialize},
9+
},
10+
};
11+
12+
/// A record of a handshake established by the note's owner (the sender).
13+
///
14+
/// Stored in [`crate::HandshakeRegistry`]'s `handshakes` set. Holds the **hash** of the master shared secret.
15+
/// A contract that wants to use this handshake will later prove the note's existence and
16+
/// use the kernel key-validation mechanism to derive its own app-siloed secret from the master, so
17+
/// the master is never exposed to dependent contracts.
18+
#[derive(Deserialize, Eq, Packable, Serialize)]
19+
#[note]
20+
pub struct HandshakeNote {
21+
/// Hash of the master shared secret: `poseidon2_hash_with_separator([S.x, S.y], DOM_SEP__HANDSHAKE_SECRET_HASH)`
22+
/// over the raw ECDH point `S = eph_sk * recipient_address_point`.
23+
pub secret_hash: Field,
24+
/// Stored so contracts can constrain the kind of handshake they accept (e.g. an app may want to require interactive
25+
/// handshakes only).
26+
pub handshake_type: u8,
27+
/// The recipient this handshake authorizes. Part of the note preimage so a contract proving note
28+
/// existence can bind the proof to the intended recipient.
29+
pub recipient: AztecAddress,
30+
}
31+
32+
impl HandshakeNote {
33+
pub fn new(shared_secret: Point, handshake_type: u8, recipient: AztecAddress) -> Self {
34+
Self {
35+
secret_hash: poseidon2_hash_with_separator(
36+
[shared_secret.x, shared_secret.y],
37+
DOM_SEP__HANDSHAKE_SECRET_HASH,
38+
),
39+
handshake_type,
40+
recipient,
41+
}
42+
}
43+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
use aztec::macros::aztec;
2+
mod handshake_note;
3+
mod test;
4+
5+
/// Handshake type identifier for non-interactive handshakes (sender-generated random shared secret, no recipient
6+
/// signature). See [`HandshakeRegistry::non_interactive_handshake`].
7+
pub(crate) global NON_INTERACTIVE_HANDSHAKE: u8 = 1;
8+
9+
/// Registry for the constrained-delivery shared-secret handshake protocol.
10+
///
11+
/// The registry's job is to establish a master shared secret between a sender and a recipient, and to store a
12+
/// per-sender note recording that the handshake happened. A contract that later wants to use this
13+
/// handshake retrieves the note via the [`HandshakeRegistry::get_handshake`] utility, asserts it exists in the
14+
/// registry,
15+
/// and uses the kernel key-validation mechanism to derive its own app-siloed shared secret from the master hash stored
16+
/// in the note.
17+
///
18+
/// Currently only implements the non-interactive flow (see [`HandshakeRegistry::non_interactive_handshake`]).
19+
#[aztec]
20+
pub contract HandshakeRegistry {
21+
use crate::handshake_note::HandshakeNote;
22+
use crate::NON_INTERACTIVE_HANDSHAKE;
23+
use aztec::{
24+
keys::{ecdh_shared_secret::derive_ecdh_shared_secret, ephemeral::generate_positive_ephemeral_key_pair},
25+
macros::{functions::external, storage::storage},
26+
messages::message_delivery::MessageDelivery,
27+
note::note_viewer_options::NoteViewerOptions,
28+
oracle::notes::set_sender_for_tags,
29+
protocol::{
30+
address::AztecAddress, constants::DOM_SEP__NON_INTERACTIVE_HANDSHAKE_LOG_TAG, hash::compute_log_tag,
31+
traits::ToField,
32+
},
33+
state_vars::{Owned, PrivateSet},
34+
};
35+
36+
#[storage]
37+
struct Storage<Context> {
38+
/// Per-sender set of [`HandshakeNote`]s. The sender is chosen by the caller and is unconstrained throughout the
39+
/// constrained-delivery protocol.
40+
handshakes: Owned<PrivateSet<HandshakeNote, Context>, Context>,
41+
}
42+
43+
/// Performs a non-interactive handshake from `sender` to `recipient`.
44+
///
45+
/// Generates a fresh ephemeral key pair `(eph_sk, eph_pk)`, computes the raw ECDH shared secret point
46+
/// `S = eph_sk * recipient_address_point`, and produces two effects:
47+
///
48+
/// 1. Inserts a [`HandshakeNote`] owned by `sender`.
49+
/// 2. Emits a 1-field private log under a recipient-keyed tag with payload `[eph_pk.x]`. The recipient
50+
/// discovers handshakes addressed to them by scanning their tag and recovers the master shared secret
51+
/// from `eph_pk` via their own ECDH (`recipient_isk * eph_pk`). `eph_pk.y` is fixed positive by the
52+
/// [`generate_positive_ephemeral_key_pair`] convention, so only `eph_pk.x` is transmitted.
53+
///
54+
/// # Panics
55+
/// If `recipient` is not a valid curve point. There are no upstream side effects in this call frame to
56+
/// protect, and a fallback would insert a permanent note recording a handshake with an invalid recipient,
57+
/// polluting registry state.
58+
#[external("private")]
59+
fn non_interactive_handshake(sender: AztecAddress, recipient: AztecAddress) {
60+
let recipient_point = recipient.to_address_point().expect(f"recipient address is not on the curve");
61+
62+
let (eph_sk, eph_pk) = generate_positive_ephemeral_key_pair();
63+
let s_raw = derive_ecdh_shared_secret(eph_sk, recipient_point.inner);
64+
65+
let note = HandshakeNote::new(s_raw, NON_INTERACTIVE_HANDSHAKE, recipient);
66+
67+
// Safety: The sender for tags is only used to compute unconstrained shared secrets for emitting logs, so it
68+
// is safe to set from a constrained context.
69+
unsafe { set_sender_for_tags(sender) };
70+
// Delivery is `ONCHAIN_UNCONSTRAINED`. The recipient is not involved in this note's delivery. They
71+
// discover the handshake via the recipient-keyed log emitted below, not via the sender's note.
72+
// We use onchain unconstrained delivery rather than `OFFCHAIN` so the note is
73+
// discoverable via normal PXE sync.
74+
self.storage.handshakes.at(sender).insert(note).deliver(MessageDelivery.ONCHAIN_UNCONSTRAINED);
75+
76+
let log_tag = compute_log_tag(
77+
recipient.to_field(),
78+
DOM_SEP__NON_INTERACTIVE_HANDSHAKE_LOG_TAG,
79+
);
80+
self.context.emit_private_log_vec_unsafe(log_tag, BoundedVec::from_array([eph_pk.x]));
81+
}
82+
83+
/// Returns the most recently inserted [`HandshakeNote`] held for `sender` matching
84+
/// `(recipient, handshake_type)`, or `None` if no such note exists.
85+
///
86+
/// Returning the latest (rather than the first) lets a sender re-initiate by issuing a fresh handshake; e.g.
87+
/// if the previous secret was leaked.
88+
///
89+
/// This is the discovery surface a contract uses to obtain a note hint before asserting existence
90+
/// of the note at this registry's address.
91+
#[external("utility")]
92+
unconstrained fn get_handshake(
93+
sender: AztecAddress,
94+
recipient: AztecAddress,
95+
handshake_type: u8,
96+
) -> Option<HandshakeNote> {
97+
// We pull all notes for `sender` and filter (recipient, handshake_type) below, walking from the end so the
98+
// first hit is the most recently inserted match.
99+
let notes = self.storage.handshakes.at(sender).view_notes(NoteViewerOptions::new());
100+
let mut found = Option::none();
101+
let len = notes.len();
102+
for i in 0..len {
103+
let note = notes.get(len - 1 - i);
104+
if (note.recipient == recipient) & (note.handshake_type == handshake_type) {
105+
found = Option::some(note);
106+
break;
107+
}
108+
}
109+
found
110+
}
111+
}

0 commit comments

Comments
 (0)