diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index 2fac2e8b8cee..e7da9874fd5c 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,6 +9,27 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD +### [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 +authorizes a combined hash over the app payload, the `fee_payment_method` selector, and the `cancellable` flag, so +the account's approval covers the fee-payer and cancellation side effects rather than the call list alone. This +prevents a party holding an account's authorization witness (for example a relayer or delegated prover) from +reusing it while switching the fee-payment mode or cancellation flag. + +This changes the authorized message preimage, so it is a breaking change: + +- **In-repo account contracts** all delegate to `AccountActions::entrypoint`, so they pick up the change on + recompilation with no source edits. Recompile and redeploy account contracts against the new library. +- **Old deployed account bytecode** does not authenticate against witnesses produced by the updated client, and + the updated bytecode does not accept witnesses produced by an old client. Client and account bytecode must be + upgraded together. +- **Third-party wallets or tooling** that build the account entrypoint authorization witness themselves (mirroring + `DefaultAccountEntrypoint`) must include the fee-payment method and cancellation flag in the witnessed hash, + using the new `entrypoint_payload` domain separator, instead of hashing the encoded calls alone. + +Gas settings are not yet bound; that is planned as a follow-up. + ### [Aztec.js] A function's return type is a single `returnType`, not a `returnTypes` list `FunctionAbi.returnTypes` is deprecated in favor of the single optional `FunctionAbi.returnType`, since multiple return values are already expressed as one `tuple` type. Read it through `getFunctionReturnType(abi)`, which also resolves artifacts serialized before `returnType` existed. `FunctionCall.returnTypes` is likewise replaced by `FunctionCall.returnType`. 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 b161ae3a97b3..59a91a3adb00 100644 --- a/noir-projects/labs/aztec-nr/aztec/src/authwit/account.nr +++ b/noir-projects/labs/aztec-nr/aztec/src/authwit/account.nr @@ -10,6 +10,26 @@ use crate::authwit::entrypoint::app::AppPayload; /// Used to produce cancellable (replaceable) transactions. pub(crate) global DOM_SEP__TX_NULLIFIER: u32 = 1025801951; +/// Domain separator for the account entrypoint payload authorization message. +/// +/// Binds the fee-payment selector and cancellation flag alongside the app payload so that the account authorizes +/// them together. +pub(crate) global DOM_SEP__ENTRYPOINT_PAYLOAD: u32 = 3045079954; + +/// 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 +/// cancellation side effects, so authorizing the app payload alone would let a party holding the witness switch the +/// fee-payment mode or cancellation flag while keeping the account's approval valid. The gas settings are not yet +/// bound: doing so requires the self-paid-deploy flow to build this witness with the settings resolved during send +/// preparation. +fn compute_entrypoint_payload_hash(app_payload: AppPayload, fee_payment_method: u8, cancellable: bool) -> Field { + poseidon2_hash_with_separator( + [app_payload.hash(), fee_payment_method as Field, cancellable as Field], + DOM_SEP__ENTRYPOINT_PAYLOAD, + ) +} + pub struct AccountActions { context: Context, is_valid_impl: fn(&mut PrivateContext, Field) -> bool, @@ -39,7 +59,15 @@ pub global AccountFeePaymentMethodOptions: AccountFeePaymentMethodOptionsEnum = /// Implements logic to verify authorization and execute payloads. impl AccountActions<&mut PrivateContext> { - /// Verifies that the `app_hash` is authorized and executes the `app_payload`. + /// 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. + /// + /// The message is a generic authwit message (`compute_authwit_message_hash`) with the account itself as the + /// consumer. This shared message space is deliberate: authorizing a witness for a third party is equivalent to + /// authorizing that party to transact as the account, so the two are not separated. See the trust level section + /// in `crate::authwit::auth`. /// /// @param app_payload The payload that contains the calls to be executed in the app phase. /// @@ -60,11 +88,12 @@ impl AccountActions<&mut PrivateContext> { pub fn entrypoint(self, app_payload: AppPayload, fee_payment_method: u8, cancellable: bool) { 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( self.context.this_address(), self.context.chain_id(), self.context.version(), - app_payload.hash(), + inner_hash, ); assert(valid_fn(self.context, message_hash)); 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..08ebba35a703 100644 --- a/noir-projects/labs/aztec-nr/aztec/src/authwit/auth.nr +++ b/noir-projects/labs/aztec-nr/aztec/src/authwit/auth.nr @@ -22,6 +22,18 @@ pub(crate) global DOM_SEP__AUTHWIT_NULLIFIER: u32 = 1239150694; /// 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. /// +/// ## Trust level +/// +/// Producing a witness for a third party grants execution authority, not a narrow permission. A witness is a bearer +/// capability over the authorized message: whoever holds it can consume it in any transaction, at any time, in any +/// surrounding call context, until it is nullified. It is not bound to the transaction it was requested for and it +/// does not expire. +/// +/// An account's entrypoint authorization deliberately lives in this same message space (see +/// `crate::authwit::account`), with the account itself as the `consumer`. A party that can obtain witnesses of its +/// choosing from an account can therefore act as that account. Treat anyone you produce witnesses for as fully +/// trusted with the account, not as holding a lesser capability than sending a transaction. +/// /// 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 /// signature is the "evidence", that the signer has seen the message, agrees with it, and has allowed it. It does not 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 96aa56a8567c..e9a823ddb712 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__TX_NULLIFIER; +use crate::authwit::account::{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; @@ -123,6 +123,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__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/aztec.js/src/wallet/capabilities.ts b/yarn-project/aztec.js/src/wallet/capabilities.ts index f8643faab6e3..fe6c0b96609c 100644 --- a/yarn-project/aztec.js/src/wallet/capabilities.ts +++ b/yarn-project/aztec.js/src/wallet/capabilities.ts @@ -61,7 +61,13 @@ export interface AccountsCapability { /** Can get accounts from wallet. Maps to: getAccounts */ canGet?: boolean; - /** Can create auth witnesses for accounts. Maps to: createAuthWit */ + /** + * Can create auth witnesses for accounts. Maps to: createAuthWit + * + * Not a lesser capability than transaction authority: a holder able to request witnesses of its choosing can + * transact as the account. Grant only to parties trusted with the account, regardless of any per-contract or + * per-function restriction applied to the transaction capability. + */ canCreateAuthWit?: boolean; } diff --git a/yarn-project/entrypoints/src/account_entrypoint.test.ts b/yarn-project/entrypoints/src/account_entrypoint.test.ts new file mode 100644 index 000000000000..10eac06cd0b1 --- /dev/null +++ b/yarn-project/entrypoints/src/account_entrypoint.test.ts @@ -0,0 +1,84 @@ +import { poseidon2HashBytes } from '@aztec/foundation/crypto/poseidon'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { AuthWitness } from '@aztec/stdlib/auth-witness'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { GasSettings } from '@aztec/stdlib/gas'; +import { ExecutionPayload } from '@aztec/stdlib/tx'; + +import { + AccountFeePaymentMethodOptions, + DefaultAccountEntrypoint, + type DefaultAccountEntrypointOptions, + ENTRYPOINT_PAYLOAD_DOMAIN_SEPARATOR, +} from './account_entrypoint.js'; +import type { AuthWitnessProvider, ChainInfo } from './interfaces.js'; + +describe('DefaultAccountEntrypoint', () => { + // Returns the message hash as the witness request hash, so tests can observe exactly what the account is asked + // to authorize without involving keys. + const authWitnessProvider: AuthWitnessProvider = { + createAuthWit: (messageHash: Fr | Buffer) => + Promise.resolve( + new AuthWitness(Fr.fromBuffer(Buffer.isBuffer(messageHash) ? messageHash : messageHash.toBuffer()), []), + ), + }; + + const address = AztecAddress.fromNumberUnsafe(42); + const chainInfo: ChainInfo = { chainId: new Fr(1), version: new Fr(2) }; + + const gasSettings = GasSettings.from({ + gasLimits: { daGas: 100, l2Gas: 200 }, + teardownGasLimits: { daGas: 10, l2Gas: 20 }, + maxFeesPerGas: { feePerDaGas: 3n, feePerL2Gas: 4n }, + maxPriorityFeesPerGas: { feePerDaGas: 1n, feePerL2Gas: 2n }, + }); + + const baseOptions: DefaultAccountEntrypointOptions = { + txNonce: new Fr(7), + cancellable: false, + feePaymentMethodOptions: AccountFeePaymentMethodOptions.EXTERNAL, + }; + + const getPayloadAuthWitnessHash = async (options: DefaultAccountEntrypointOptions): Promise => { + const entrypoint = new DefaultAccountEntrypoint(address, authWitnessProvider); + const request = await entrypoint.createTxExecutionRequest( + ExecutionPayload.empty(), + gasSettings, + chainInfo, + options, + ); + return request.authWitnesses.at(-1)!.requestHash; + }; + + it('computes the same payload auth witness for identical requests', async () => { + const first = await getPayloadAuthWitnessHash(baseOptions); + const second = await getPayloadAuthWitnessHash(baseOptions); + expect(first.equals(second)).toBe(true); + }); + + it.each([ + [AccountFeePaymentMethodOptions.EXTERNAL, AccountFeePaymentMethodOptions.PREEXISTING_FEE_JUICE], + [AccountFeePaymentMethodOptions.EXTERNAL, AccountFeePaymentMethodOptions.FEE_JUICE_WITH_CLAIM], + [AccountFeePaymentMethodOptions.PREEXISTING_FEE_JUICE, AccountFeePaymentMethodOptions.FEE_JUICE_WITH_CLAIM], + ])('binds the fee payment method into the payload auth witness (%i vs %i)', async (from, to) => { + const first = await getPayloadAuthWitnessHash({ ...baseOptions, feePaymentMethodOptions: from }); + const second = await getPayloadAuthWitnessHash({ ...baseOptions, feePaymentMethodOptions: to }); + expect(first.equals(second)).toBe(false); + }); + + it('binds the cancellable flag into the payload auth witness', async () => { + const first = await getPayloadAuthWitnessHash({ ...baseOptions, cancellable: false }); + const second = await getPayloadAuthWitnessHash({ ...baseOptions, cancellable: true }); + expect(first.equals(second)).toBe(false); + }); + + // Guards against drift from the Noir DOM_SEP__ENTRYPOINT_PAYLOAD, which is hand-mirrored here. Re-derives the + // value from the separator name the same way the Noir domain separators are derived (poseidon over the + // "az_dom_sep__" byte string, truncated to a u32) rather than pinning a magic number. + it('mirrors the Noir entrypoint payload domain separator', async () => { + const derived = Number( + (await poseidon2HashBytes(Buffer.from('az_dom_sep__entrypoint_payload'))).toBigInt() & 0xffffffffn, + ); + expect(ENTRYPOINT_PAYLOAD_DOMAIN_SEPARATOR).toEqual(derived); + }); +}); diff --git a/yarn-project/entrypoints/src/account_entrypoint.ts b/yarn-project/entrypoints/src/account_entrypoint.ts index c6d3e0d6ba51..7584c969ec6f 100644 --- a/yarn-project/entrypoints/src/account_entrypoint.ts +++ b/yarn-project/entrypoints/src/account_entrypoint.ts @@ -1,3 +1,4 @@ +import { poseidon2HashWithSeparator } from '@aztec/foundation/crypto/poseidon'; import { Fr } from '@aztec/foundation/curves/bn254'; import { type FunctionAbi, @@ -14,6 +15,30 @@ import { ExecutionPayload, HashedValues, TxContext, TxExecutionRequest } from '@ import { EncodedAppEntrypointCalls } from './encoding.js'; import type { AuthWitnessProvider, ChainInfo, EntrypointInterface } from './interfaces.js'; +/** + * Domain separator for the account entrypoint payload authorization message. Mirrors DOM_SEP__ENTRYPOINT_PAYLOAD + * in noir-projects/labs/aztec-nr/aztec/src/authwit/account.nr. Derived from the poseidon hash of + * "az_dom_sep__entrypoint_payload" 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_PAYLOAD_DOMAIN_SEPARATOR = 3045079954; + +/** + * 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 so that the account's approval covers the fee-payer + * and cancellation side effects rather than the call list alone. + */ +export async function computeEntrypointPayloadHash( + encodedCalls: EncodedAppEntrypointCalls, + feePaymentMethod: AccountFeePaymentMethodOptions, + cancellable: boolean, +): Promise { + return poseidon2HashWithSeparator( + [await encodedCalls.hash(), new Fr(feePaymentMethod), new Fr(cancellable)], + ENTRYPOINT_PAYLOAD_DOMAIN_SEPARATOR, + ); +} + /** * The mechanism via which an account contract will pay for a transaction in which it gets invoked. */ @@ -142,7 +167,7 @@ export class DefaultAccountEntrypoint implements EntrypointInterface { const functionSelector = await FunctionSelector.fromNameAndParameters(abi.name, abi.parameters); - const payloadHash = await encodedCalls.hash(); + const payloadHash = await computeEntrypointPayloadHash(encodedCalls, feePaymentMethodOptions, !!cancellable); const messageHash = await computeOuterAuthWitHash(this.address, chainInfo.chainId, chainInfo.version, payloadHash); const payloadAuthWitness = await this.auth.createAuthWit(messageHash);