diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index b8a99d9634fb..b22b6d1b7fcf 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,6 +9,20 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD +### [aztec-nr / Aztec.js] Account entrypoint authorization is now domain-separated from generic authwits + +The account entrypoint previously wrapped its payload hash with the generic authwit outer hash +(`compute_authwit_message_hash` / `computeOuterAuthWitHash`). That placed the authorized message in the image of the +generic authwit path: a `createAuthWit` over `{ consumer: account, innerHash: }` produces +exactly the message the entrypoint validates, so anyone able to request a generic authwit from the account could mint +an entrypoint authorization for a call list of their choosing. The entrypoint now wraps the payload hash with a +dedicated `entrypoint_message` domain separator, so the message can no longer be produced through the generic path. + +This changes the authorized message preimage again, so it is a breaking change with the same upgrade requirements as +above: client and account bytecode must be upgraded together. **Third-party wallets or tooling** that build the +entrypoint authorization witness themselves must wrap the payload hash with the new `entrypoint_message` domain +separator instead of the generic outer authwit hash. + ### [aztec-nr / Aztec.js] Account entrypoint authorization now binds the fee-payment method and cancellation flag The account entrypoint (`AccountActions::entrypoint`) previously authorized only the app payload hash. It now diff --git a/noir-projects/labs/aztec-nr/aztec/src/authwit/account.nr b/noir-projects/labs/aztec-nr/aztec/src/authwit/account.nr index b55d04ae7df6..7f71fd3d0a74 100644 --- a/noir-projects/labs/aztec-nr/aztec/src/authwit/account.nr +++ b/noir-projects/labs/aztec-nr/aztec/src/authwit/account.nr @@ -1,6 +1,6 @@ use crate::context::PrivateContext; -use crate::protocol::{hash::poseidon2_hash_with_separator, traits::Hash}; +use crate::protocol::{address::AztecAddress, hash::poseidon2_hash_with_separator, traits::{Hash, ToField}}; use crate::authwit::auth::{compute_authwit_message_hash, IS_VALID_SELECTOR}; use crate::authwit::entrypoint::app::AppPayload; @@ -16,6 +16,15 @@ pub(crate) global DOM_SEP__TX_NULLIFIER: u32 = 1025801951; /// them together. pub(crate) global DOM_SEP__ENTRYPOINT_PAYLOAD: u32 = 3045079954; +/// Domain separator for the account entrypoint authorization message. +/// +/// The entrypoint authorizes `compute_entrypoint_message_hash`, which wraps the payload hash with this separator +/// rather than the generic authwit outer separator. Sharing the generic separator would place the entrypoint +/// message in the image of the generic authwit path (`compute_authwit_message_hash` over an attacker-chosen inner +/// hash), letting anyone able to request a generic authwit from the account mint an entrypoint authorization for a +/// call list of their choosing. +pub(crate) global DOM_SEP__ENTRYPOINT_MESSAGE: u32 = 3858756027; + /// Computes the inner hash the account authorizes when invoked through its entrypoint. /// /// Binds the app payload together with the fee-payment selector and cancellation flag. Both drive fee-payer and @@ -30,6 +39,22 @@ fn compute_entrypoint_payload_hash(app_payload: AppPayload, fee_payment_method: ) } +/// Computes the message the account authorizes when invoked through its entrypoint. +/// +/// Mirrors `compute_authwit_message_hash` but uses `DOM_SEP__ENTRYPOINT_MESSAGE`, so reproducing the entrypoint +/// message through the generic authwit path would require a Poseidon2 collision. +fn compute_entrypoint_message_hash( + consumer: AztecAddress, + chain_id: Field, + version: Field, + inner_hash: Field, +) -> Field { + poseidon2_hash_with_separator( + [consumer.to_field(), chain_id, version, inner_hash], + DOM_SEP__ENTRYPOINT_MESSAGE, + ) +} + pub struct AccountActions { context: Context, is_valid_impl: fn(&mut PrivateContext, Field) -> bool, @@ -62,7 +87,9 @@ impl AccountActions<&mut PrivateContext> { /// Verifies that the entrypoint payload is authorized and executes the `app_payload`. /// /// The authorized message binds the app payload together with `fee_payment_method` and `cancellable`, so the - /// account's approval covers the fee-payer and cancellation side effects rather than the call list alone. + /// account's approval covers the fee-payer and cancellation side effects rather than the call list alone. It is + /// domain-separated from generic authwits (see `DOM_SEP__ENTRYPOINT_MESSAGE`) so it cannot be minted through + /// the generic authwit path. /// /// @param app_payload The payload that contains the calls to be executed in the app phase. /// @@ -84,7 +111,7 @@ impl AccountActions<&mut PrivateContext> { let valid_fn = self.is_valid_impl; let inner_hash = compute_entrypoint_payload_hash(app_payload, fee_payment_method, cancellable); - let message_hash = compute_authwit_message_hash( + let message_hash = compute_entrypoint_message_hash( self.context.this_address(), self.context.chain_id(), self.context.version(), diff --git a/noir-projects/labs/aztec-nr/aztec/src/authwit/auth.nr b/noir-projects/labs/aztec-nr/aztec/src/authwit/auth.nr index 53c744465485..04102ba9a785 100644 --- a/noir-projects/labs/aztec-nr/aztec/src/authwit/auth.nr +++ b/noir-projects/labs/aztec-nr/aztec/src/authwit/auth.nr @@ -19,8 +19,12 @@ pub(crate) global DOM_SEP__AUTHWIT_NULLIFIER: u32 = 1239150694; /// Authentication witness helper library /// -/// Authentication Witness is a scheme for authenticating actions on Aztec, so users can allow third-parties (e.g. -/// protocols or other users) to execute an action on their behalf. +/// Authentication Witness is a scheme for authorizing application-level actions on Aztec, so users can allow +/// third-parties (e.g. protocols or other users) to perform an action that a consuming contract checks. +/// +/// A witness authorizes one action against one consumer. It does not authorize originating a transaction as the +/// account: an account's entrypoint message uses its own domain separator (see `DOM_SEP__ENTRYPOINT_MESSAGE` in +/// `crate::authwit::account`). /// /// This library provides helper functions to manage such witnesses. The authentication witness, is some "witness" /// (data) that authenticates a `message_hash`. The simplest example of an authentication witness, is a signature. The diff --git a/noir-projects/labs/aztec-nr/aztec/src/test/domain_separators.nr b/noir-projects/labs/aztec-nr/aztec/src/test/domain_separators.nr index e9a823ddb712..387988a2d136 100644 --- a/noir-projects/labs/aztec-nr/aztec/src/test/domain_separators.nr +++ b/noir-projects/labs/aztec-nr/aztec/src/test/domain_separators.nr @@ -8,7 +8,7 @@ //! that the whole set (every aztec-nr separator plus every protocol separator) is collision-free. The protocol enforces //! the same for its own separators in its own tests. -use crate::authwit::account::{DOM_SEP__ENTRYPOINT_PAYLOAD, DOM_SEP__TX_NULLIFIER}; +use crate::authwit::account::{DOM_SEP__ENTRYPOINT_MESSAGE, DOM_SEP__ENTRYPOINT_PAYLOAD, DOM_SEP__TX_NULLIFIER}; use crate::authwit::auth::DOM_SEP__AUTHWIT_NULLIFIER; use crate::keys::ecdh_shared_secret::{DOM_SEP__ECDH_FIELD_MASK, DOM_SEP__ECDH_SUBKEY}; use crate::macros::functions::initialization_utils::DOM_SEP__INITIALIZATION_NULLIFIER; @@ -124,6 +124,7 @@ unconstrained fn domain_separators_are_valid() { ); all = all.push_back(derived(DOM_SEP__TX_NULLIFIER, "tx_nullifier")); all = all.push_back(derived(DOM_SEP__ENTRYPOINT_PAYLOAD, "entrypoint_payload")); + all = all.push_back(derived(DOM_SEP__ENTRYPOINT_MESSAGE, "entrypoint_message")); all = all.push_back(derived(DOM_SEP__AUTHWIT_NULLIFIER, "authwit_nullifier")); all = all.push_back(derived(DOM_SEP__ECDH_SUBKEY, "ecdh_subkey")); all = all.push_back(derived(DOM_SEP__ECDH_FIELD_MASK, "ecdh_field_mask")); diff --git a/yarn-project/entrypoints/src/account_entrypoint.test.ts b/yarn-project/entrypoints/src/account_entrypoint.test.ts index 10eac06cd0b1..807930b861d0 100644 --- a/yarn-project/entrypoints/src/account_entrypoint.test.ts +++ b/yarn-project/entrypoints/src/account_entrypoint.test.ts @@ -1,6 +1,6 @@ import { poseidon2HashBytes } from '@aztec/foundation/crypto/poseidon'; import { Fr } from '@aztec/foundation/curves/bn254'; -import { AuthWitness } from '@aztec/stdlib/auth-witness'; +import { AuthWitness, computeOuterAuthWitHash } from '@aztec/stdlib/auth-witness'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { GasSettings } from '@aztec/stdlib/gas'; import { ExecutionPayload } from '@aztec/stdlib/tx'; @@ -9,8 +9,12 @@ import { AccountFeePaymentMethodOptions, DefaultAccountEntrypoint, type DefaultAccountEntrypointOptions, + ENTRYPOINT_MESSAGE_DOMAIN_SEPARATOR, ENTRYPOINT_PAYLOAD_DOMAIN_SEPARATOR, + computeEntrypointMessageHash, + computeEntrypointPayloadHash, } from './account_entrypoint.js'; +import { EncodedAppEntrypointCalls } from './encoding.js'; import type { AuthWitnessProvider, ChainInfo } from './interfaces.js'; describe('DefaultAccountEntrypoint', () => { @@ -81,4 +85,36 @@ describe('DefaultAccountEntrypoint', () => { ); expect(ENTRYPOINT_PAYLOAD_DOMAIN_SEPARATOR).toEqual(derived); }); + + it('mirrors the Noir entrypoint message domain separator', async () => { + const derived = Number( + (await poseidon2HashBytes(Buffer.from('az_dom_sep__entrypoint_message'))).toBigInt() & 0xffffffffn, + ); + expect(ENTRYPOINT_MESSAGE_DOMAIN_SEPARATOR).toEqual(derived); + }); + + // The entrypoint must authorize a message the generic authwit path cannot reproduce. Otherwise a party able to + // request a generic authwit from the account (a createAuthWit over { consumer: account, innerHash }) could mint an + // entrypoint authorization for a call list of its choosing. + it('domain-separates the entrypoint message from the generic authwit hash', async () => { + const hash = await getPayloadAuthWitnessHash(baseOptions); + + const encodedCalls = await EncodedAppEntrypointCalls.create(ExecutionPayload.empty().calls, baseOptions.txNonce); + const payloadHash = await computeEntrypointPayloadHash( + encodedCalls, + baseOptions.feePaymentMethodOptions, + !!baseOptions.cancellable, + ); + + const entrypointMessage = await computeEntrypointMessageHash( + address, + chainInfo.chainId, + chainInfo.version, + payloadHash, + ); + const genericAuthwit = await computeOuterAuthWitHash(address, chainInfo.chainId, chainInfo.version, payloadHash); + + expect(hash.equals(entrypointMessage)).toBe(true); + expect(hash.equals(genericAuthwit)).toBe(false); + }); }); diff --git a/yarn-project/entrypoints/src/account_entrypoint.ts b/yarn-project/entrypoints/src/account_entrypoint.ts index 7584c969ec6f..0b93db891e58 100644 --- a/yarn-project/entrypoints/src/account_entrypoint.ts +++ b/yarn-project/entrypoints/src/account_entrypoint.ts @@ -7,7 +7,6 @@ import { encodeArguments, getFunctionReturnType, } from '@aztec/stdlib/abi'; -import { computeOuterAuthWitHash } from '@aztec/stdlib/auth-witness'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { GasSettings } from '@aztec/stdlib/gas'; import { ExecutionPayload, HashedValues, TxContext, TxExecutionRequest } from '@aztec/stdlib/tx'; @@ -39,6 +38,31 @@ export async function computeEntrypointPayloadHash( ); } +/** + * Domain separator for the account entrypoint authorization message. Mirrors DOM_SEP__ENTRYPOINT_MESSAGE in + * noir-projects/labs/aztec-nr/aztec/src/authwit/account.nr. Derived from the poseidon hash of + * "az_dom_sep__entrypoint_message" truncated to a u32; kept in TypeScript by hand because the generated constants + * package only carries protocol-circuit constants, and pinned by a drift test that re-derives it. + */ +export const ENTRYPOINT_MESSAGE_DOMAIN_SEPARATOR = 3858756027; + +/** + * Computes the message the account authorizes when invoked through its entrypoint. Wraps the entrypoint payload hash + * with a dedicated domain separator rather than the generic authwit outer hash, so the message cannot be reproduced + * through the generic authwit path (a `createAuthWit` over `{ consumer: account, innerHash }`). + */ +export function computeEntrypointMessageHash( + consumer: AztecAddress, + chainId: Fr, + version: Fr, + payloadHash: Fr, +): Promise { + return poseidon2HashWithSeparator( + [consumer.toField(), chainId, version, payloadHash], + ENTRYPOINT_MESSAGE_DOMAIN_SEPARATOR, + ); +} + /** * The mechanism via which an account contract will pay for a transaction in which it gets invoked. */ @@ -168,7 +192,12 @@ export class DefaultAccountEntrypoint implements EntrypointInterface { const functionSelector = await FunctionSelector.fromNameAndParameters(abi.name, abi.parameters); const payloadHash = await computeEntrypointPayloadHash(encodedCalls, feePaymentMethodOptions, !!cancellable); - const messageHash = await computeOuterAuthWitHash(this.address, chainInfo.chainId, chainInfo.version, payloadHash); + const messageHash = await computeEntrypointMessageHash( + this.address, + chainInfo.chainId, + chainInfo.version, + payloadHash, + ); const payloadAuthWitness = await this.auth.createAuthWit(messageHash); return {