-
Notifications
You must be signed in to change notification settings - Fork 612
feat(pxe): resolve tagging secret source via wallet hook #24040
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: merge-train/fairies-v5
Are you sure you want to change the base?
Changes from 12 commits
df57245
26917d5
36c849f
ea9bf23
f5d14ac
2ea81d0
4454c2c
1c6c807
f04873c
d550918
5fc1e29
a9d6a84
a55134f
19ba78d
3e5e021
f00d8eb
48a022f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| --- | ||
| title: Execution hooks | ||
| sidebar_position: 3 | ||
| tags: [pxe, wallets] | ||
| description: How wallets use PXE execution hooks to apply custom policies during client-side simulation. | ||
| --- | ||
|
|
||
| Execution hooks are callbacks that the PXE invokes during client-side simulation when an operation needs a decision from the wallet. They let the wallet apply its own policies before execution proceeds, such as prompting the user, consulting a dynamic allowlist, or inspecting call arguments. All hooks are optional, and when a hook is absent the PXE applies a safe default. | ||
|
|
||
| ## Configuring hooks | ||
|
|
||
| Pass a `hooks` object when creating the PXE: | ||
|
|
||
| ```typescript | ||
| import { createPXE } from "@aztec/pxe/server"; | ||
|
|
||
| const pxe = await createPXE(node, config, { | ||
| hooks: { | ||
| // Allow calls to a known helper contract, deny everything else. | ||
| authorizeUtilityCall: async (request) => { | ||
| return request.target.equals(trustedHelper) | ||
| ? { authorized: true } | ||
| : { authorized: false, reason: "Unknown target" }; | ||
| }, | ||
| // When there's no established way to reach the recipient, fall back to a non-interactive handshake. | ||
| resolveTaggingSecret: async () => ({ type: "non-interactive-handshake" }), | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| ## `authorizeUtilityCall` | ||
|
|
||
| Called whenever a utility function makes a cross-contract call. A call made by a malicious contract could leak private information, so the hook lets the wallet decide, per call, whether to allow it. A static allowlist would not work here because neither the app nor the wallet can predict ahead of time which contracts will be invoked during execution: permission must be asked after execution has begun. Calls to standard contracts (such as the HandshakeRegistry, which is queried during every contract's sync) bypass this hook and are always authorized. | ||
|
nchamo marked this conversation as resolved.
|
||
|
|
||
| Unlike [authentication witnesses (authwits)](../../aztec-js/how_to_use_authwit.md), the hook is invoked live, while execution is underway. Authwits can be recorded during simulation and signed once at the end, but the PXE cannot predict what a utility call would return, so it must ask before continuing. Most of the time the wallet should answer on its own, for example against a list of audited or previously trusted contracts, to avoid interrupting execution multiple times asking the user for confirmation. | ||
|
|
||
| ### Deciding what to authorize | ||
|
|
||
| Private state is siloed per contract: a utility function runs on your device with access to its own contract's private state, and nothing else. Reading your own balance through a token contract's utility function is fine, and the hook never fires, because no contract boundary is crossed. The risk appears only when one contract's utility function calls into a *different* contract, because that call can reach private state the caller could not read on its own. | ||
|
|
||
| Consider a single cross-contract operation, reading your token balance, made by two different callers. When a DeFi router calls the token's balance utility to quote you a swap, that is a legitimate cross-contract read, and you want it allowed. When an unknown, possibly malicious contract makes the very same call to snoop your balance, you want it denied. The exposed data is identical in both cases; the only thing that differs is *who is making the call*, which is exactly the decision the hook delegates to the wallet. | ||
|
|
||
| The wallet makes that decision by inspecting the request, which identifies the caller and target by both address and contract class ID, to judge whether the call is safe to authorize. | ||
|
|
||
| ### In Noir tests | ||
|
|
||
| When testing cross-contract utility calls in Noir using `TestEnvironment`, use `with_authorized_utility_call_targets` on your call options: | ||
|
|
||
| ```rust | ||
| // For private calls: | ||
| env.call_private_opts( | ||
| account, | ||
| CallPrivateOptions::new().with_authorized_utility_call_targets([target_address]), | ||
|
nchamo marked this conversation as resolved.
|
||
| MyContract::at(caller).some_private_fn(), | ||
| ); | ||
|
|
||
| // For private view calls: | ||
| env.view_private_opts( | ||
| account, | ||
| ViewPrivateOptions::new().with_authorized_utility_call_targets([target_address]), | ||
| MyContract::at(caller).some_view_fn(), | ||
| ); | ||
|
|
||
| // For utility calls: | ||
| env.execute_utility_opts( | ||
| ExecuteUtilityOptions::new().with_authorized_utility_call_targets([target_address]), | ||
| MyContract::at(caller).some_utility_fn(), | ||
| ); | ||
| ``` | ||
|
|
||
| ### In production | ||
|
|
||
| Pass an `authorizeUtilityCall` hook when [creating the PXE](#configuring-hooks). It receives a `UtilityCallAuthorizationRequest` with the caller and target addresses, their contract class IDs, the function selector, the function name, the arguments, and the caller context (`'private'`, `'private view'`, or `'utility'`). Return `{ authorized: true }` to allow the call, or `{ authorized: false, reason: '...' }` to deny it with a message. | ||
|
|
||
| When the hook is absent, cross-contract utility calls are denied. See [Cross-contract utility call denied](../../aztec-nr/debugging.md#cross-contract-utility-call-denied) for the resulting error. | ||
|
|
||
| ## `resolveTaggingSecret` | ||
|
|
||
| Called as a fallback when message delivery has no established tagging secret to reuse for a sender-recipient pair: an established secret is reused without invoking the hook, so it only fires when none exists yet. The wallet returns a concrete `TaggingSecretSource` (and any material the chosen derivation needs); see [Tagging secret source](../../aztec-nr/framework-description/note_delivery.md#tagging-secret-source) for the variants, the trade-offs, and the defaults in each environment. | ||
|
|
||
| ### In Noir tests | ||
|
|
||
| When testing in Noir, leaving the source unset makes `TestEnvironment` fall back to the bare PXE default. Set a source when creating the environment to exercise a specific one; it affects message delivery in private executions: | ||
|
|
||
| ```rust | ||
| let env = TestEnvironment::new_opts( | ||
| TestEnvironmentOptions::new().with_tagging_secret_source(TaggingSecretSource::non_interactive_handshake()), | ||
| ); | ||
| ``` | ||
|
|
||
| ### In production | ||
|
|
||
| Pass a `resolveTaggingSecret` hook when [creating the PXE](#configuring-hooks). It receives a `TaggingSecretSourceRequest` with the executing contract's address and the message's sender, recipient, and delivery mode (`'constrained'` or `'unconstrained'`), so a wallet can apply per-application or per-recipient policies, or surface the decision to the user, instead of returning a fixed value. | ||
|
|
||
| When the hook is absent, the PXE applies a privacy-safe default: unconstrained delivery uses an address-derived (Diffie-Hellman) shared secret, which leaves no onchain trace, while constrained delivery fails rather than silently revealing the recipient through a non-interactive handshake. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| use crate::protocol::{traits::{Deserialize, Serialize}, utils::reader::Reader}; | ||
|
|
||
| global NON_INTERACTIVE_HANDSHAKE: u8 = 1; | ||
| global SHARED_SECRET: u8 = 2; | ||
|
|
||
| /// How the tagging secret for a message is sourced, as decided by the wallet through the | ||
| /// [`resolve_tagging_secret`](crate::oracle::resolve_tagging_secret) oracle. | ||
| /// | ||
| /// Message delivery tags each on-chain message so the recipient can find it without scanning every log. The tag is | ||
| /// derived from a secret shared between sender and recipient. When one has already been established for the pair it is | ||
| /// reused. Otherwise, the wallet must decide how to source it: this type is that decision, carrying any material the | ||
| /// chosen derivation needs. | ||
| #[derive(Eq, Serialize)] | ||
| pub struct TaggingSecretSource { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This would be source and secret, correct? Because we do have Also try to make it so public nr docs have their first paragraph always be a very short single line (e.g. 'a tagging shared secret and its source'), expanding in future paragraphs. since the first par is what the docsite renders on a preview (see e.g. how the 'modules' list here is fairly clean https://docs.aztec.network/aztec-nr-api/mainnet/noir_aztec/index.html, while this is messier https://docs.aztec.network/aztec-nr-api/mainnet/noir_aztec/oracle/notes/). The same advice applies to all fns in the impl below.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I improved the docs, but I'm not sure what you mean by:
Are you suggesting we rename |
||
| kind: u8, | ||
| secret: Field, | ||
| } | ||
|
|
||
| impl TaggingSecretSource { | ||
| /// A secret established through a non-interactive handshake, derivable by the recipient from the on-chain handshake | ||
| /// registry. | ||
| pub fn non_interactive_handshake() -> Self { | ||
| Self { kind: NON_INTERACTIVE_HANDSHAKE, secret: 0 } | ||
| } | ||
|
|
||
| /// A secret the two parties already share off-chain: they must have coordinated somehow to both know it (e.g. | ||
| /// derived via Diffie-Hellman from each other's address keys). Because nothing on-chain proves the recipient knows | ||
| /// it, this is only sound for unconstrained delivery. | ||
| pub fn shared_secret(secret: Field) -> Self { | ||
| Self { kind: SHARED_SECRET, secret } | ||
| } | ||
|
|
||
| /// Validates a raw discriminant, as deserialization must always reject unknown values. | ||
| fn from_parts(kind: u8, secret: Field) -> Self { | ||
| let source = Self { kind, secret }; | ||
| assert( | ||
| (kind == NON_INTERACTIVE_HANDSHAKE) | (kind == SHARED_SECRET), | ||
| f"unrecognized tagging secret source kind: {kind}", | ||
| ); | ||
| source | ||
| } | ||
| } | ||
|
|
||
| impl Deserialize for TaggingSecretSource { | ||
| let N: u32 = 2; | ||
|
|
||
| fn deserialize(fields: [Field; Self::N]) -> Self { | ||
| Self::from_parts(fields[0] as u8, fields[1]) | ||
| } | ||
|
|
||
| fn stream_deserialize<let K: u32>(reader: &mut Reader<K>) -> Self { | ||
| let kind = reader.read() as u8; | ||
| let secret = reader.read(); | ||
| Self::from_parts(kind, secret) | ||
| } | ||
| } | ||
|
|
||
| mod test { | ||
| use crate::protocol::traits::{Deserialize, Serialize}; | ||
| use super::TaggingSecretSource; | ||
|
|
||
| #[test] | ||
| fn source_roundtrips_through_serialization() { | ||
| let non_interactive = TaggingSecretSource::non_interactive_handshake(); | ||
| let shared = TaggingSecretSource::shared_secret(42); | ||
|
|
||
| assert(TaggingSecretSource::deserialize(non_interactive.serialize()) == non_interactive); | ||
| assert(TaggingSecretSource::deserialize(shared.serialize()) == shared); | ||
| } | ||
|
|
||
| #[test(should_fail_with = "unrecognized tagging secret source kind")] | ||
| fn deserializing_invalid_kind_fails() { | ||
| let _ = TaggingSecretSource::deserialize([99, 0]); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| use crate::messages::delivery::{OnchainDeliveryMode, TaggingSecretSource}; | ||
| use crate::oracle::random::random; | ||
| use crate::protocol::address::AztecAddress; | ||
|
|
||
| /// Sources the tagging secret for a message to `recipient`. | ||
| /// | ||
| /// Consulted only when no tagging secret has been established for the `(sender, recipient)` pair yet; an established | ||
| /// secret is reused without asking the wallet. See [`TaggingSecretSource`] for the variants and their trade-offs. | ||
| /// | ||
| /// An invalid recipient has no shared secret to derive. Unconstrained delivery returns a random source (an | ||
| /// undiscoverable tag) rather than letting a malformed recipient abort the send; constrained delivery instead fails, | ||
| /// since it cannot be served and must not silently emit an undiscoverable tag. | ||
| pub(crate) unconstrained fn resolve_tagging_secret( | ||
| sender: AztecAddress, | ||
| recipient: AztecAddress, | ||
| mode: OnchainDeliveryMode, | ||
| ) -> TaggingSecretSource { | ||
| if recipient.is_valid() { | ||
| resolve_tagging_secret_oracle(sender, recipient, mode) | ||
| } else if mode == OnchainDeliveryMode::onchain_constrained() { | ||
| // Constrained delivery to an invalid recipient cannot be served, so fail. | ||
| panic( | ||
| "Cannot resolve a constrained tagging secret for an invalid recipient", | ||
| ) | ||
| } else { | ||
| // Unconstrained is best-effort: a random source yields an undiscoverable tag instead of failing the send. | ||
| TaggingSecretSource::shared_secret(random()) | ||
| } | ||
| } | ||
|
|
||
| #[oracle(aztec_prv_resolveTaggingSecret)] | ||
| unconstrained fn resolve_tagging_secret_oracle( | ||
| _sender: AztecAddress, | ||
| _recipient: AztecAddress, | ||
| _mode: OnchainDeliveryMode, | ||
| ) -> TaggingSecretSource {} | ||
|
|
||
| mod test { | ||
| use crate::messages::delivery::{OnchainDeliveryMode, TaggingSecretSource}; | ||
| use crate::protocol::{address::AztecAddress, traits::FromField}; | ||
| use super::resolve_tagging_secret; | ||
| use std::test::OracleMock; | ||
|
|
||
| // x = 8 is a valid address (8^3 - 17 is a residue); x = 3 is not (see AztecAddress::is_valid). | ||
| global VALID_RECIPIENT: AztecAddress = AztecAddress::from_field(8); | ||
| global INVALID_RECIPIENT: AztecAddress = AztecAddress::from_field(3); | ||
|
|
||
| #[test] | ||
| unconstrained fn asks_the_wallet_for_a_valid_recipient() { | ||
| let source = TaggingSecretSource::shared_secret(123); | ||
| let _ = OracleMock::mock("aztec_prv_resolveTaggingSecret").returns(source); | ||
|
|
||
| let resolved = resolve_tagging_secret( | ||
| AztecAddress::from_field(1), | ||
| VALID_RECIPIENT, | ||
| OnchainDeliveryMode::onchain_unconstrained(), | ||
| ); | ||
| assert_eq(resolved, source); | ||
| } | ||
|
|
||
| #[test] | ||
| unconstrained fn uses_a_random_source_for_an_invalid_recipient_when_unconstrained() { | ||
| let random_secret = 999; | ||
| let _ = OracleMock::mock("aztec_misc_getRandomField").returns(random_secret); | ||
| let resolve_oracle = OracleMock::mock("aztec_prv_resolveTaggingSecret").returns( | ||
| TaggingSecretSource::non_interactive_handshake(), | ||
| ); | ||
|
|
||
| let resolved = resolve_tagging_secret( | ||
| AztecAddress::from_field(1), | ||
| INVALID_RECIPIENT, | ||
| OnchainDeliveryMode::onchain_unconstrained(), | ||
| ); | ||
| assert_eq(resolved, TaggingSecretSource::shared_secret(random_secret)); | ||
|
|
||
| // An invalid recipient must short-circuit to the random source without consulting the wallet. | ||
| assert_eq(resolve_oracle.times_called(), 0); | ||
| } | ||
|
|
||
| #[test(should_fail_with = "Cannot resolve a constrained tagging secret for an invalid recipient")] | ||
| unconstrained fn fails_for_an_invalid_recipient_when_constrained() { | ||
| let _ = resolve_tagging_secret( | ||
| AztecAddress::from_field(1), | ||
| INVALID_RECIPIENT, | ||
| OnchainDeliveryMode::onchain_constrained(), | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you mean when an onchain handshake exists, correct? not necessarily that any shared secret has already been used, rather that one has been registered (an ecdh handshake would not be registered)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are right, I made it clearer