diff --git a/yarn-project/end-to-end/src/automine/delivery/interactive_handshake_responder.ts b/yarn-project/end-to-end/src/automine/delivery/interactive_handshake_responder.ts new file mode 100644 index 000000000000..e46d0fa2b4d5 --- /dev/null +++ b/yarn-project/end-to-end/src/automine/delivery/interactive_handshake_responder.ts @@ -0,0 +1,125 @@ +import { AztecAddress, type CompleteAddress } from '@aztec/aztec.js/addresses'; +import { DomainSeparator } from '@aztec/constants'; +import { poseidon2HashWithSeparator } from '@aztec/foundation/crypto/poseidon'; +import { Schnorr, type SchnorrSignature } from '@aztec/foundation/crypto/schnorr'; +import { Fq, Fr } from '@aztec/foundation/curves/bn254'; +import type { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin'; +import type { CustomRequest } from '@aztec/pxe/config'; +import { + INTERACTIVE_HANDSHAKE_REQUEST_KIND, + STANDARD_HANDSHAKE_REGISTRY_ADDRESS, +} from '@aztec/standard-contracts/handshake-registry/constants'; +import { type PublicKeys, derivePublicKeyFromSecretKey } from '@aztec/stdlib/keys'; + +/** + * The decoded payload of the registry's interactive-handshake signature request. Note it never carries the sender, + * so the recipient authorizes the handshake without learning who initiated it. + */ +export type InteractiveHandshakeRequest = { + /** The account whose authorization is being requested. */ + recipient: AztecAddress; + chainId: Fr; + version: Fr; + /** The x-coordinate of the handshake's ephemeral public key (its y-coordinate is positive by construction). */ + ephPkX: Fr; +}; + +/** + * The recipient's signed authorization for an interactive handshake. Mirrors the registry contract's + * `RecipientSignature` struct field for field. + */ +export type RecipientSignature = { + /** The recipient's master public keys, bound in-circuit to the recipient's address via `partialAddress`. */ + publicKeys: PublicKeys; + partialAddress: Fr; + /** The x-coordinate of the recipient's master message-signing public key. */ + mspkX: Fr; + /** Whether that key's y-coordinate is positive, so the circuit can reconstruct the point from `mspkX`. */ + mspkYIsPositive: boolean; + /** The schnorr signature over the handshake message. */ + signature: SchnorrSignature; +}; + +/** + * Parses and validates the registry's interactive-handshake signature request. + * + * @throws If the request kind is not {@link INTERACTIVE_HANDSHAKE_REQUEST_KIND}, the issuing contract is not the + * standard HandshakeRegistry, or the payload does not have the expected shape. + */ +export function parseInteractiveHandshakeRequest(request: CustomRequest): InteractiveHandshakeRequest { + if (!request.kind.equals(INTERACTIVE_HANDSHAKE_REQUEST_KIND)) { + throw new Error(`Not an interactive-handshake signature request: unexpected kind ${request.kind}`); + } + if (!request.contractAddress.equals(STANDARD_HANDSHAKE_REGISTRY_ADDRESS)) { + throw new Error( + `Interactive-handshake signature request issued by ${request.contractAddress}, expected the standard HandshakeRegistry at ${STANDARD_HANDSHAKE_REGISTRY_ADDRESS}`, + ); + } + if (request.payload.length !== 4) { + throw new Error(`Interactive-handshake signature request payload has ${request.payload.length} fields, expected 4`); + } + + const [recipient, chainId, version, ephPkX] = request.payload; + return { recipient: new AztecAddress(recipient), chainId, version, ephPkX }; +} + +/** + * Produces the recipient's signed authorization for an interactive handshake, signing with the master + * message-signing secret key. + */ +export async function signInteractiveHandshake( + request: InteractiveHandshakeRequest, + completeAddress: CompleteAddress, + masterMessageSigningSecretKey: GrumpkinScalar, +): Promise { + const mspk = await derivePublicKeyFromSecretKey(masterMessageSigningSecretKey); + const [mspkX, mspkYIsPositive] = mspk.toXAndSign(); + + const message = await computeInteractiveHandshakeSignatureMessage({ + chainId: request.chainId, + version: request.version, + registry: STANDARD_HANDSHAKE_REGISTRY_ADDRESS, + ephPkX: request.ephPkX, + }); + const signature = await new Schnorr().constructSignature(message, masterMessageSigningSecretKey); + + return { + publicKeys: completeAddress.publicKeys, + partialAddress: completeAddress.partialAddress, + mspkX, + mspkYIsPositive, + signature, + }; +} + +/** Serializes a {@link RecipientSignature} to the field layout the registry's in-circuit deserialization expects. */ +export function recipientSignatureToFields(recipientSignature: RecipientSignature): Fr[] { + const s = Fq.fromBuffer(recipientSignature.signature.s); + const e = Fq.fromBuffer(recipientSignature.signature.e); + return [ + ...recipientSignature.publicKeys.toFields(), + recipientSignature.partialAddress, + recipientSignature.mspkX, + new Fr(recipientSignature.mspkYIsPositive), + s.lo, + s.hi, + e.lo, + e.hi, + ]; +} + +/** + * The message an interactive-handshake authorization signs: the handshake's ephemeral key and chain context under + * `DomainSeparator.INTERACTIVE_HANDSHAKE_SIGNATURE`, exactly as the registry recomputes it in-circuit. + */ +function computeInteractiveHandshakeSignatureMessage(args: { + chainId: Fr; + version: Fr; + registry: AztecAddress; + ephPkX: Fr; +}): Promise { + return poseidon2HashWithSeparator( + [args.chainId, args.version, args.registry, args.ephPkX], + DomainSeparator.INTERACTIVE_HANDSHAKE_SIGNATURE, + ); +} diff --git a/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts b/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts index cdab97abcf31..0e0dfaeb5039 100644 --- a/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts @@ -1,5 +1,15 @@ +import type { InitialAccountData } from '@aztec/accounts/testing'; +import type { CompleteAddress } from '@aztec/aztec.js/addresses'; import { Point } from '@aztec/foundation/curves/grumpkin'; +import type { ResolveCustomRequest } from '@aztec/pxe/config'; +import { deriveKeys } from '@aztec/stdlib/keys'; +import type { TestWallet } from '../../test-wallet/test_wallet.js'; +import { + parseInteractiveHandshakeRequest, + recipientSignatureToFields, + signInteractiveHandshake, +} from './interactive_handshake_responder.js'; import { buildMessageDeliveryTest } from './onchain_delivery_harness.js'; describe('onchain delivery', () => { @@ -34,8 +44,8 @@ describe('onchain delivery', () => { }, }); - // With the recipient registering the sender, - // the recipient PXE reconstructs the address-derived tag and discovers the delivery. + // With the recipient registering the sender, the recipient PXE reconstructs the address-derived tag + // and discovers the delivery. buildMessageDeliveryTest({ strategy: 'address-derived', mode: 'unconstrained', @@ -44,4 +54,47 @@ describe('onchain delivery', () => { await recipientWallet.registerSender(senderAddress); }, }); + + buildMessageDeliveryTest({ + strategy: 'interactive handshake', + mode: 'constrained', + senderHook: () => Promise.resolve({ type: 'interactive-handshake' }), + customRequestResponder: interactiveHandshakeResponder, + }); + + buildMessageDeliveryTest({ + strategy: 'interactive handshake', + mode: 'unconstrained', + senderHook: () => Promise.resolve({ type: 'interactive-handshake' }), + customRequestResponder: interactiveHandshakeResponder, + }); + + // Serves the registry's interactive-handshake signature request for the recipient: registers the handshake on the + // recipient PXE, then answers with the signed response. + function interactiveHandshakeResponder( + recipientWallet: TestWallet, + recipientAccount: InitialAccountData, + recipientCompleteAddress: CompleteAddress, + ): ResolveCustomRequest { + return async request => { + const parsed = parseInteractiveHandshakeRequest(request); + + // Register before signing. + await recipientWallet.registerTaggingSecretSource({ + kind: 'handshake', + recipient: parsed.recipient, + ephPk: parsed.ephPkX, + }); + + // The master message-signing secret key is deliberately never held by PXE or the key store; the wallet + // derives it client-side from the account secret. + const { masterMessageSigningSecretKey } = await deriveKeys(recipientAccount.secret); + const recipientSignature = await signInteractiveHandshake( + parsed, + recipientCompleteAddress, + masterMessageSigningSecretKey, + ); + return recipientSignatureToFields(recipientSignature); + }; + } }); diff --git a/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts b/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts index d10ae9196aea..6e00fca5969e 100644 --- a/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts +++ b/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts @@ -1,11 +1,12 @@ import { type InitialAccountData, generateSchnorrAccounts } from '@aztec/accounts/testing'; import type { FieldLike } from '@aztec/aztec.js/abi'; import { NO_FROM } from '@aztec/aztec.js/account'; -import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import type { AztecAddress, CompleteAddress } from '@aztec/aztec.js/addresses'; import type { AztecNode } from '@aztec/aztec.js/node'; +import type { AccountManager } from '@aztec/aztec.js/wallet'; import { BlockNumber } from '@aztec/foundation/branded-types'; import { type DeliveryEvent, OnchainDeliveryTestContract } from '@aztec/noir-test-contracts.js/OnchainDeliveryTest'; -import type { PXECreationOptions } from '@aztec/pxe/server'; +import type { CustomRequest, ResolveCustomRequest, ResolveTaggingSecretStrategy } from '@aztec/pxe/config'; import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client'; import { jest } from '@jest/globals'; @@ -14,9 +15,13 @@ import { AUTOMINE_E2E_OPTS } from '../../fixtures/fixtures.js'; import { ensureHandshakeRegistryPublished, setup, setupPXEAndGetWallet } from '../../fixtures/setup.js'; import { TestWallet } from '../../test-wallet/test_wallet.js'; -// The wallet hook that selects a message's tagging-secret source. Derived from the exported PXE options -// rather than importing the hook type, which `@aztec/pxe/server` does not re-export. -export type SenderHook = NonNullable['resolveTaggingSecretStrategy']>; +// Builds the hook serving custom requests issued during the sender's simulations. Installed on the sender PXE at +// creation but built only once the recipient exists, since serving typically needs the recipient's wallet and keys. +export type CustomRequestResponder = ( + recipient: TestWallet, + recipientAccount: InitialAccountData, + recipientCompleteAddress: CompleteAddress, +) => ResolveCustomRequest; export type Mode = 'constrained' | 'unconstrained'; @@ -40,19 +45,22 @@ export function buildMessageDeliveryTest(opts: { strategy: string; mode: DeliveryMode; // Required: every cell states its source explicitly rather than leaning on the PXE default (covered by unit tests). - senderHook: SenderHook; + senderHook: ResolveTaggingSecretStrategy; // Recipient-side setup the source requires (e.g. registering a raw arbitrary secret); runs once after deployment. recipientRegistration?: ( recipient: TestWallet, recipientAddress: AztecAddress, sender: AztecAddress, ) => Promise; + // Serves the custom requests issued during the sender's simulations (e.g. the registry's interactive-handshake + // signature request). + customRequestResponder?: CustomRequestResponder; // Extra `it()`s to register in this cell's suite, e.g. assertions against state a custom `senderHook` recorded. // Called inside the same `describe`, after the two baseline assertions below, so it shares their `beforeAll` // instead of depending on Jest's cross-`describe` execution order. additionalTests?: () => void; }) { - const { strategy, mode, senderHook, recipientRegistration, additionalTests } = opts; + const { strategy, mode, senderHook, recipientRegistration, customRequestResponder, additionalTests } = opts; const description = `${strategy} x ${formatMode(mode)}`; describe(description, () => { @@ -84,11 +92,14 @@ export function buildMessageDeliveryTest(opts: { ? contractSender.methods.emit_note(recipient, value) : contractSender.methods.emit_note_unconstrained(recipient, value); + let additionallyFundedAccounts: InitialAccountData[]; + let recipientAccount: AccountManager | undefined; + let customRequestCount = 0; + beforeAll(async () => { // The sender PXE holds the sender and carries this cell's tagging-secret-strategy hook. The recipient is funded // at genesis here but created and deployed on the isolated recipient PXE below, so it carries no sender state // from other cells. - let additionallyFundedAccounts: InitialAccountData[]; ({ aztecNode, additionallyFundedAccounts, @@ -98,7 +109,26 @@ export function buildMessageDeliveryTest(opts: { } = await setup(1, { ...AUTOMINE_E2E_OPTS, additionallyFundedAccounts: await generateSchnorrAccounts(1, 'schnorr'), - pxeCreationOptions: { hooks: { resolveTaggingSecretStrategy: senderHook } }, + pxeCreationOptions: { + hooks: { + resolveTaggingSecretStrategy: senderHook, + resolveCustomRequest: async (request: CustomRequest) => { + if (!customRequestResponder) { + throw new Error('A custom request arrived but this test cell has no customRequestResponder configured'); + } + if (!recipientAccount) { + throw new Error('A custom request arrived before the recipient wallet was created'); + } + customRequestCount++; + const respond = customRequestResponder( + walletRecipient, + additionallyFundedAccounts[0], + await recipientAccount.getCompleteAddress(), + ); + return respond(request); + }, + }, + }, })); ({ wallet: walletRecipient, teardown: teardownRecipient } = await setupPXEAndGetWallet( @@ -108,7 +138,7 @@ export function buildMessageDeliveryTest(opts: { undefined, 'pxe-recipient', )); - const recipientAccount = await walletRecipient.createSchnorrAccount( + recipientAccount = await walletRecipient.createSchnorrAccount( additionallyFundedAccounts[0].secret, additionallyFundedAccounts[0].salt, additionallyFundedAccounts[0].signingKey, @@ -172,6 +202,12 @@ export function buildMessageDeliveryTest(opts: { expect(readNotes).toEqual(noteValues); }); + if (customRequestResponder) { + it('the custom request hook fires exactly once, on the send that bootstraps the tagging secret', () => { + expect(customRequestCount).toBe(1); + }); + } + additionalTests?.(); }); } diff --git a/yarn-project/standard-contracts/src/handshake-registry/constants.ts b/yarn-project/standard-contracts/src/handshake-registry/constants.ts index 48b4cd153728..0573b2b125d5 100644 --- a/yarn-project/standard-contracts/src/handshake-registry/constants.ts +++ b/yarn-project/standard-contracts/src/handshake-registry/constants.ts @@ -1,6 +1,8 @@ // Lightweight metadata leaf export for browser bundles: importing from // `@aztec/standard-contracts/handshake-registry/constants` avoids dragging in the // `HandshakeRegistry.json` static import. +import { sha256ToField } from '@aztec/foundation/crypto/sha256'; +import type { Fr } from '@aztec/foundation/curves/bn254'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import { StandardContractAddress, StandardContractClassId, StandardContractSalt } from '../standard_contract_data.js'; @@ -8,3 +10,14 @@ import { StandardContractAddress, StandardContractClassId, StandardContractSalt export const STANDARD_HANDSHAKE_REGISTRY_ADDRESS: AztecAddress = StandardContractAddress.HandshakeRegistry; export const STANDARD_HANDSHAKE_REGISTRY_CLASS_ID = StandardContractClassId.HandshakeRegistry; export const STANDARD_HANDSHAKE_REGISTRY_SALT = StandardContractSalt.HandshakeRegistry; + +/** + * Request kind under which the HandshakeRegistry asks for a recipient's interactive-handshake signature through the + * `resolveCustomRequest` hook. Mirrors `INTERACTIVE_HANDSHAKE_REQUEST_KIND` in the registry contract. + */ +// TODO: remove this mirrored constant and read the value from the HandshakeRegistry artifact once the contract +// global can be `#[abi]`-exported. Fixed upstream but not yet released: +// https://github.com/noir-lang/noir/pull/12714 and https://github.com/noir-lang/noir/issues/12620. +export const INTERACTIVE_HANDSHAKE_REQUEST_KIND: Fr = sha256ToField([ + Buffer.from('HANDSHAKE_REGISTRY::INTERACTIVE_HANDSHAKE_REQUEST'), +]); diff --git a/yarn-project/standard-contracts/src/handshake-registry/index.ts b/yarn-project/standard-contracts/src/handshake-registry/index.ts index f10e7186a45f..2cc367647d2e 100644 --- a/yarn-project/standard-contracts/src/handshake-registry/index.ts +++ b/yarn-project/standard-contracts/src/handshake-registry/index.ts @@ -6,6 +6,7 @@ import { makeStandardContract } from '../make_standard_contract.js'; import type { StandardContract } from '../standard_contract.js'; export { + INTERACTIVE_HANDSHAKE_REQUEST_KIND, STANDARD_HANDSHAKE_REGISTRY_ADDRESS, STANDARD_HANDSHAKE_REGISTRY_CLASS_ID, STANDARD_HANDSHAKE_REGISTRY_SALT,