Skip to content

Commit ab53cc3

Browse files
nchamoaztec-bot
authored andcommitted
feat(e2e): interactive handshake e2e (#24590)
(cherry picked from commit 23e8e1e)
1 parent 2fd66d3 commit ab53cc3

5 files changed

Lines changed: 240 additions & 12 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { AztecAddress, type CompleteAddress } from '@aztec/aztec.js/addresses';
2+
import { DomainSeparator } from '@aztec/constants';
3+
import { poseidon2HashWithSeparator } from '@aztec/foundation/crypto/poseidon';
4+
import { Schnorr, type SchnorrSignature } from '@aztec/foundation/crypto/schnorr';
5+
import { Fq, Fr } from '@aztec/foundation/curves/bn254';
6+
import type { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin';
7+
import type { CustomRequest } from '@aztec/pxe/config';
8+
import {
9+
INTERACTIVE_HANDSHAKE_REQUEST_KIND,
10+
STANDARD_HANDSHAKE_REGISTRY_ADDRESS,
11+
} from '@aztec/standard-contracts/handshake-registry/constants';
12+
import { type PublicKeys, derivePublicKeyFromSecretKey } from '@aztec/stdlib/keys';
13+
14+
/**
15+
* The decoded payload of the registry's interactive-handshake signature request. Note it never carries the sender,
16+
* so the recipient authorizes the handshake without learning who initiated it.
17+
*/
18+
export type InteractiveHandshakeRequest = {
19+
/** The account whose authorization is being requested. */
20+
recipient: AztecAddress;
21+
chainId: Fr;
22+
version: Fr;
23+
/** The x-coordinate of the handshake's ephemeral public key (its y-coordinate is positive by construction). */
24+
ephPkX: Fr;
25+
};
26+
27+
/**
28+
* The recipient's signed authorization for an interactive handshake. Mirrors the registry contract's
29+
* `RecipientSignature` struct field for field.
30+
*/
31+
export type RecipientSignature = {
32+
/** The recipient's master public keys, bound in-circuit to the recipient's address via `partialAddress`. */
33+
publicKeys: PublicKeys;
34+
partialAddress: Fr;
35+
/** The x-coordinate of the recipient's master message-signing public key. */
36+
mspkX: Fr;
37+
/** Whether that key's y-coordinate is positive, so the circuit can reconstruct the point from `mspkX`. */
38+
mspkYIsPositive: boolean;
39+
/** The schnorr signature over the handshake message. */
40+
signature: SchnorrSignature;
41+
};
42+
43+
/**
44+
* Parses and validates the registry's interactive-handshake signature request.
45+
*
46+
* @throws If the request kind is not {@link INTERACTIVE_HANDSHAKE_REQUEST_KIND}, the issuing contract is not the
47+
* standard HandshakeRegistry, or the payload does not have the expected shape.
48+
*/
49+
export function parseInteractiveHandshakeRequest(request: CustomRequest): InteractiveHandshakeRequest {
50+
if (!request.kind.equals(INTERACTIVE_HANDSHAKE_REQUEST_KIND)) {
51+
throw new Error(`Not an interactive-handshake signature request: unexpected kind ${request.kind}`);
52+
}
53+
if (!request.contractAddress.equals(STANDARD_HANDSHAKE_REGISTRY_ADDRESS)) {
54+
throw new Error(
55+
`Interactive-handshake signature request issued by ${request.contractAddress}, expected the standard HandshakeRegistry at ${STANDARD_HANDSHAKE_REGISTRY_ADDRESS}`,
56+
);
57+
}
58+
if (request.payload.length !== 4) {
59+
throw new Error(`Interactive-handshake signature request payload has ${request.payload.length} fields, expected 4`);
60+
}
61+
62+
const [recipient, chainId, version, ephPkX] = request.payload;
63+
return { recipient: new AztecAddress(recipient), chainId, version, ephPkX };
64+
}
65+
66+
/**
67+
* Produces the recipient's signed authorization for an interactive handshake, signing with the master
68+
* message-signing secret key.
69+
*/
70+
export async function signInteractiveHandshake(
71+
request: InteractiveHandshakeRequest,
72+
completeAddress: CompleteAddress,
73+
masterMessageSigningSecretKey: GrumpkinScalar,
74+
): Promise<RecipientSignature> {
75+
const mspk = await derivePublicKeyFromSecretKey(masterMessageSigningSecretKey);
76+
const [mspkX, mspkYIsPositive] = mspk.toXAndSign();
77+
78+
const message = await computeInteractiveHandshakeSignatureMessage({
79+
chainId: request.chainId,
80+
version: request.version,
81+
registry: STANDARD_HANDSHAKE_REGISTRY_ADDRESS,
82+
ephPkX: request.ephPkX,
83+
});
84+
const signature = await new Schnorr().constructSignature(message, masterMessageSigningSecretKey);
85+
86+
return {
87+
publicKeys: completeAddress.publicKeys,
88+
partialAddress: completeAddress.partialAddress,
89+
mspkX,
90+
mspkYIsPositive,
91+
signature,
92+
};
93+
}
94+
95+
/** Serializes a {@link RecipientSignature} to the field layout the registry's in-circuit deserialization expects. */
96+
export function recipientSignatureToFields(recipientSignature: RecipientSignature): Fr[] {
97+
const s = Fq.fromBuffer(recipientSignature.signature.s);
98+
const e = Fq.fromBuffer(recipientSignature.signature.e);
99+
return [
100+
...recipientSignature.publicKeys.toFields(),
101+
recipientSignature.partialAddress,
102+
recipientSignature.mspkX,
103+
new Fr(recipientSignature.mspkYIsPositive),
104+
s.lo,
105+
s.hi,
106+
e.lo,
107+
e.hi,
108+
];
109+
}
110+
111+
/**
112+
* The message an interactive-handshake authorization signs: the handshake's ephemeral key and chain context under
113+
* `DomainSeparator.INTERACTIVE_HANDSHAKE_SIGNATURE`, exactly as the registry recomputes it in-circuit.
114+
*/
115+
function computeInteractiveHandshakeSignatureMessage(args: {
116+
chainId: Fr;
117+
version: Fr;
118+
registry: AztecAddress;
119+
ephPkX: Fr;
120+
}): Promise<Fr> {
121+
return poseidon2HashWithSeparator(
122+
[args.chainId, args.version, args.registry, args.ephPkX],
123+
DomainSeparator.INTERACTIVE_HANDSHAKE_SIGNATURE,
124+
);
125+
}

yarn-project/end-to-end/src/automine/delivery/onchain.test.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1+
import type { InitialAccountData } from '@aztec/accounts/testing';
2+
import type { CompleteAddress } from '@aztec/aztec.js/addresses';
13
import { Point } from '@aztec/foundation/curves/grumpkin';
4+
import type { ResolveCustomRequest } from '@aztec/pxe/config';
5+
import { deriveKeys } from '@aztec/stdlib/keys';
26

7+
import type { TestWallet } from '../../test-wallet/test_wallet.js';
8+
import {
9+
parseInteractiveHandshakeRequest,
10+
recipientSignatureToFields,
11+
signInteractiveHandshake,
12+
} from './interactive_handshake_responder.js';
313
import { buildMessageDeliveryTest } from './onchain_delivery_harness.js';
414

515
describe('onchain delivery', () => {
@@ -34,8 +44,8 @@ describe('onchain delivery', () => {
3444
},
3545
});
3646

37-
// With the recipient registering the sender,
38-
// the recipient PXE reconstructs the address-derived tag and discovers the delivery.
47+
// With the recipient registering the sender, the recipient PXE reconstructs the address-derived tag
48+
// and discovers the delivery.
3949
buildMessageDeliveryTest({
4050
strategy: 'address-derived',
4151
mode: 'unconstrained',
@@ -44,4 +54,47 @@ describe('onchain delivery', () => {
4454
await recipientWallet.registerSender(senderAddress);
4555
},
4656
});
57+
58+
buildMessageDeliveryTest({
59+
strategy: 'interactive handshake',
60+
mode: 'constrained',
61+
senderHook: () => Promise.resolve({ type: 'interactive-handshake' }),
62+
customRequestResponder: interactiveHandshakeResponder,
63+
});
64+
65+
buildMessageDeliveryTest({
66+
strategy: 'interactive handshake',
67+
mode: 'unconstrained',
68+
senderHook: () => Promise.resolve({ type: 'interactive-handshake' }),
69+
customRequestResponder: interactiveHandshakeResponder,
70+
});
71+
72+
// Serves the registry's interactive-handshake signature request for the recipient: registers the handshake on the
73+
// recipient PXE, then answers with the signed response.
74+
function interactiveHandshakeResponder(
75+
recipientWallet: TestWallet,
76+
recipientAccount: InitialAccountData,
77+
recipientCompleteAddress: CompleteAddress,
78+
): ResolveCustomRequest {
79+
return async request => {
80+
const parsed = parseInteractiveHandshakeRequest(request);
81+
82+
// Register before signing.
83+
await recipientWallet.registerTaggingSecretSource({
84+
kind: 'handshake',
85+
recipient: parsed.recipient,
86+
ephPk: parsed.ephPkX,
87+
});
88+
89+
// The master message-signing secret key is deliberately never held by PXE or the key store; the wallet
90+
// derives it client-side from the account secret.
91+
const { masterMessageSigningSecretKey } = await deriveKeys(recipientAccount.secret);
92+
const recipientSignature = await signInteractiveHandshake(
93+
parsed,
94+
recipientCompleteAddress,
95+
masterMessageSigningSecretKey,
96+
);
97+
return recipientSignatureToFields(recipientSignature);
98+
};
99+
}
47100
});

yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { type InitialAccountData, generateSchnorrAccounts } from '@aztec/accounts/testing';
22
import type { FieldLike } from '@aztec/aztec.js/abi';
33
import { NO_FROM } from '@aztec/aztec.js/account';
4-
import type { AztecAddress } from '@aztec/aztec.js/addresses';
4+
import type { AztecAddress, CompleteAddress } from '@aztec/aztec.js/addresses';
55
import type { AztecNode } from '@aztec/aztec.js/node';
6+
import type { AccountManager } from '@aztec/aztec.js/wallet';
67
import { BlockNumber } from '@aztec/foundation/branded-types';
78
import { type DeliveryEvent, OnchainDeliveryTestContract } from '@aztec/noir-test-contracts.js/OnchainDeliveryTest';
8-
import type { PXECreationOptions } from '@aztec/pxe/server';
9+
import type { CustomRequest, ResolveCustomRequest, ResolveTaggingSecretStrategy } from '@aztec/pxe/config';
910
import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client';
1011

1112
import { jest } from '@jest/globals';
@@ -14,9 +15,13 @@ import { AUTOMINE_E2E_OPTS } from '../../fixtures/fixtures.js';
1415
import { ensureHandshakeRegistryPublished, setup, setupPXEAndGetWallet } from '../../fixtures/setup.js';
1516
import { TestWallet } from '../../test-wallet/test_wallet.js';
1617

17-
// The wallet hook that selects a message's tagging-secret source. Derived from the exported PXE options
18-
// rather than importing the hook type, which `@aztec/pxe/server` does not re-export.
19-
export type SenderHook = NonNullable<NonNullable<PXECreationOptions['hooks']>['resolveTaggingSecretStrategy']>;
18+
// Builds the hook serving custom requests issued during the sender's simulations. Installed on the sender PXE at
19+
// creation but built only once the recipient exists, since serving typically needs the recipient's wallet and keys.
20+
export type CustomRequestResponder = (
21+
recipient: TestWallet,
22+
recipientAccount: InitialAccountData,
23+
recipientCompleteAddress: CompleteAddress,
24+
) => ResolveCustomRequest;
2025

2126
export type Mode = 'constrained' | 'unconstrained';
2227

@@ -40,19 +45,22 @@ export function buildMessageDeliveryTest(opts: {
4045
strategy: string;
4146
mode: DeliveryMode;
4247
// Required: every cell states its source explicitly rather than leaning on the PXE default (covered by unit tests).
43-
senderHook: SenderHook;
48+
senderHook: ResolveTaggingSecretStrategy;
4449
// Recipient-side setup the source requires (e.g. registering a raw arbitrary secret); runs once after deployment.
4550
recipientRegistration?: (
4651
recipient: TestWallet,
4752
recipientAddress: AztecAddress,
4853
sender: AztecAddress,
4954
) => Promise<void>;
55+
// Serves the custom requests issued during the sender's simulations (e.g. the registry's interactive-handshake
56+
// signature request).
57+
customRequestResponder?: CustomRequestResponder;
5058
// Extra `it()`s to register in this cell's suite, e.g. assertions against state a custom `senderHook` recorded.
5159
// Called inside the same `describe`, after the two baseline assertions below, so it shares their `beforeAll`
5260
// instead of depending on Jest's cross-`describe` execution order.
5361
additionalTests?: () => void;
5462
}) {
55-
const { strategy, mode, senderHook, recipientRegistration, additionalTests } = opts;
63+
const { strategy, mode, senderHook, recipientRegistration, customRequestResponder, additionalTests } = opts;
5664
const description = `${strategy} x ${formatMode(mode)}`;
5765

5866
describe(description, () => {
@@ -84,11 +92,14 @@ export function buildMessageDeliveryTest(opts: {
8492
? contractSender.methods.emit_note(recipient, value)
8593
: contractSender.methods.emit_note_unconstrained(recipient, value);
8694

95+
let additionallyFundedAccounts: InitialAccountData[];
96+
let recipientAccount: AccountManager | undefined;
97+
let customRequestCount = 0;
98+
8799
beforeAll(async () => {
88100
// The sender PXE holds the sender and carries this cell's tagging-secret-strategy hook. The recipient is funded
89101
// at genesis here but created and deployed on the isolated recipient PXE below, so it carries no sender state
90102
// from other cells.
91-
let additionallyFundedAccounts: InitialAccountData[];
92103
({
93104
aztecNode,
94105
additionallyFundedAccounts,
@@ -98,7 +109,26 @@ export function buildMessageDeliveryTest(opts: {
98109
} = await setup(1, {
99110
...AUTOMINE_E2E_OPTS,
100111
additionallyFundedAccounts: await generateSchnorrAccounts(1, 'schnorr'),
101-
pxeCreationOptions: { hooks: { resolveTaggingSecretStrategy: senderHook } },
112+
pxeCreationOptions: {
113+
hooks: {
114+
resolveTaggingSecretStrategy: senderHook,
115+
resolveCustomRequest: async (request: CustomRequest) => {
116+
if (!customRequestResponder) {
117+
throw new Error('A custom request arrived but this test cell has no customRequestResponder configured');
118+
}
119+
if (!recipientAccount) {
120+
throw new Error('A custom request arrived before the recipient wallet was created');
121+
}
122+
customRequestCount++;
123+
const respond = customRequestResponder(
124+
walletRecipient,
125+
additionallyFundedAccounts[0],
126+
await recipientAccount.getCompleteAddress(),
127+
);
128+
return respond(request);
129+
},
130+
},
131+
},
102132
}));
103133

104134
({ wallet: walletRecipient, teardown: teardownRecipient } = await setupPXEAndGetWallet(
@@ -108,7 +138,7 @@ export function buildMessageDeliveryTest(opts: {
108138
undefined,
109139
'pxe-recipient',
110140
));
111-
const recipientAccount = await walletRecipient.createSchnorrAccount(
141+
recipientAccount = await walletRecipient.createSchnorrAccount(
112142
additionallyFundedAccounts[0].secret,
113143
additionallyFundedAccounts[0].salt,
114144
additionallyFundedAccounts[0].signingKey,
@@ -172,6 +202,12 @@ export function buildMessageDeliveryTest(opts: {
172202
expect(readNotes).toEqual(noteValues);
173203
});
174204

205+
if (customRequestResponder) {
206+
it('the custom request hook fires exactly once, on the send that bootstraps the tagging secret', () => {
207+
expect(customRequestCount).toBe(1);
208+
});
209+
}
210+
175211
additionalTests?.();
176212
});
177213
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
// Lightweight metadata leaf export for browser bundles: importing from
22
// `@aztec/standard-contracts/handshake-registry/constants` avoids dragging in the
33
// `HandshakeRegistry.json` static import.
4+
import { sha256ToField } from '@aztec/foundation/crypto/sha256';
5+
import type { Fr } from '@aztec/foundation/curves/bn254';
46
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
57

68
import { StandardContractAddress, StandardContractClassId, StandardContractSalt } from '../standard_contract_data.js';
79

810
export const STANDARD_HANDSHAKE_REGISTRY_ADDRESS: AztecAddress = StandardContractAddress.HandshakeRegistry;
911
export const STANDARD_HANDSHAKE_REGISTRY_CLASS_ID = StandardContractClassId.HandshakeRegistry;
1012
export const STANDARD_HANDSHAKE_REGISTRY_SALT = StandardContractSalt.HandshakeRegistry;
13+
14+
/**
15+
* Request kind under which the HandshakeRegistry asks for a recipient's interactive-handshake signature through the
16+
* `resolveCustomRequest` hook. Mirrors `INTERACTIVE_HANDSHAKE_REQUEST_KIND` in the registry contract.
17+
*/
18+
// TODO: remove this mirrored constant and read the value from the HandshakeRegistry artifact once the contract
19+
// global can be `#[abi]`-exported. Fixed upstream but not yet released:
20+
// https://github.com/noir-lang/noir/pull/12714 and https://github.com/noir-lang/noir/issues/12620.
21+
export const INTERACTIVE_HANDSHAKE_REQUEST_KIND: Fr = sha256ToField([
22+
Buffer.from('HANDSHAKE_REGISTRY::INTERACTIVE_HANDSHAKE_REQUEST'),
23+
]);

yarn-project/standard-contracts/src/handshake-registry/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { makeStandardContract } from '../make_standard_contract.js';
66
import type { StandardContract } from '../standard_contract.js';
77

88
export {
9+
INTERACTIVE_HANDSHAKE_REQUEST_KIND,
910
STANDARD_HANDSHAKE_REGISTRY_ADDRESS,
1011
STANDARD_HANDSHAKE_REGISTRY_CLASS_ID,
1112
STANDARD_HANDSHAKE_REGISTRY_SALT,

0 commit comments

Comments
 (0)