diff --git a/yarn-project/pxe/src/logs/log_service.test.ts b/yarn-project/pxe/src/logs/log_service.test.ts index 3a3b1120262d..05f1e006a4d4 100644 --- a/yarn-project/pxe/src/logs/log_service.test.ts +++ b/yarn-project/pxe/src/logs/log_service.test.ts @@ -1,13 +1,16 @@ import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants'; import { BlockNumber } from '@aztec/foundation/branded-types'; import { randomInt } from '@aztec/foundation/crypto/random'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { Point } from '@aztec/foundation/curves/grumpkin'; import { KeyStore } from '@aztec/key-store'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { L2TipsProvider } from '@aztec/stdlib/block'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; -import { SiloedTag, Tag } from '@aztec/stdlib/logs'; -import { makeBlockHeader, randomPrivateLogResult } from '@aztec/stdlib/testing'; +import { deriveKeys } from '@aztec/stdlib/keys'; +import { AppTaggingSecret, AppTaggingSecretKind, type LogResult, SiloedTag, Tag } from '@aztec/stdlib/logs'; +import { makeBlockHeader, makeL2Tips, randomPrivateLogResult } from '@aztec/stdlib/testing'; import { type MockProxy, mock } from 'jest-mock-extended'; @@ -322,8 +325,135 @@ describe('LogService', () => { }); }); }); + + describe('fetchTaggedLogs', () => { + let recipient: AztecAddress; + let sharedSecret: Point; + + beforeEach(async () => { + contractAddress = await AztecAddress.random(); + keyStore = new KeyStore(await openTmpStore('test')); + recipientTaggingStore = new RecipientTaggingStore(await openTmpStore('test')); + taggingSecretSourcesStore = new TaggingSecretSourcesStore(await openTmpStore('test')); + addressStore = new AddressStore(await openTmpStore('test')); + + aztecNode = mock(); + + const l2TipsProvider = mock(); + l2TipsProvider.getL2Tips.mockResolvedValue(makeL2Tips(0)); + + const completeAddress = await keyStore.addAccount(await deriveKeys(Fr.random()), Fr.random()); + await addressStore.addCompleteAddress(completeAddress); + recipient = completeAddress.address; + + sharedSecret = await Point.random(); + + const anchorBlockHeader = makeBlockHeader(randomInt(1000), { blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM) }); + + logService = new LogService( + aztecNode, + anchorBlockHeader, + l2TipsProvider, + keyStore, + recipientTaggingStore, + taggingSecretSourcesStore, + addressStore, + 'test', + ); + }); + + it('scans handshake secrets under the handshake derivation for both delivery modes', async () => { + await taggingSecretSourcesStore.addSharedSecret(recipient, 'handshake', sharedSecret); + const [unconstrainedTag, constrainedTag] = await handshakeTags(sharedSecret, contractAddress); + + const unconstrainedLog = randomPrivateLogResult({ includeEffects: true }); + const constrainedLog = randomPrivateLogResult({ includeEffects: true }); + servePrivateLogsByTag( + aztecNode, + new Map([ + [unconstrainedTag.toString(), unconstrainedLog], + [constrainedTag.toString(), constrainedLog], + ]), + ); + + const logs = await logService.fetchTaggedLogs(contractAddress, recipient, []); + + const txHashes = logs.map(l => l.context.txHash); + expect(txHashes).toContainEqual(unconstrainedLog.txHash); + expect(txHashes).toContainEqual(constrainedLog.txHash); + }); + + it('does not scan arbitrary secrets under the handshake derivation', async () => { + await taggingSecretSourcesStore.addSharedSecret(recipient, 'arbitrary-secret', sharedSecret); + const [unconstrainedTag, constrainedTag] = await handshakeTags(sharedSecret, contractAddress); + + const directionalLog = randomPrivateLogResult({ includeEffects: true }); + const handshakeStreamLog = randomPrivateLogResult({ includeEffects: true }); + servePrivateLogsByTag( + aztecNode, + new Map([ + [(await directionalTag(sharedSecret, contractAddress, recipient)).toString(), directionalLog], + [unconstrainedTag.toString(), handshakeStreamLog], + [constrainedTag.toString(), handshakeStreamLog], + ]), + ); + + const logs = await logService.fetchTaggedLogs(contractAddress, recipient, []); + + const txHashes = logs.map(l => l.context.txHash); + expect(txHashes).toContainEqual(directionalLog.txHash); + expect(txHashes).not.toContainEqual(handshakeStreamLog.txHash); + }); + + it('does not scan handshake secrets under the directional derivation', async () => { + await taggingSecretSourcesStore.addSharedSecret(recipient, 'handshake', sharedSecret); + const [unconstrainedTag] = await handshakeTags(sharedSecret, contractAddress); + + const handshakeStreamLog = randomPrivateLogResult({ includeEffects: true }); + const directionalLog = randomPrivateLogResult({ includeEffects: true }); + servePrivateLogsByTag( + aztecNode, + new Map([ + [unconstrainedTag.toString(), handshakeStreamLog], + [(await directionalTag(sharedSecret, contractAddress, recipient)).toString(), directionalLog], + ]), + ); + + const logs = await logService.fetchTaggedLogs(contractAddress, recipient, []); + + const txHashes = logs.map(l => l.context.txHash); + expect(txHashes).toContainEqual(handshakeStreamLog.txHash); + expect(txHashes).not.toContainEqual(directionalLog.txHash); + }); + + function handshakeTags(secret: Point, app: AztecAddress): Promise { + return Promise.all( + [AppTaggingSecretKind.UNCONSTRAINED, AppTaggingSecretKind.CONSTRAINED].map(async kind => + SiloedTag.compute({ extendedSecret: await AppTaggingSecret.computeAppSiloed(secret, app, kind), index: 0 }), + ), + ); + } + + async function directionalTag(secret: Point, app: AztecAddress, directedTo: AztecAddress): Promise { + const directionalSecret = await AppTaggingSecret.computeDirectional(secret, app, directedTo); + return SiloedTag.compute({ extendedSecret: directionalSecret, index: 0 }); + } + }); }); +/** Serves one private log per matching siloed tag and an empty page for every other tag. */ +function servePrivateLogsByTag(aztecNode: MockProxy, logsByTag: Map) { + aztecNode.getPrivateLogsByTags.mockImplementation(({ tags }) => + Promise.resolve( + tags.map(tagQuery => { + const tag = tagQuery instanceof SiloedTag ? tagQuery : tagQuery.tag; + const log = logsByTag.get(tag.toString()); + return log ? [log] : []; + }), + ), + ); +} + function makeLogRetrievalRequest( contractAddress: AztecAddress, tag: Tag, diff --git a/yarn-project/pxe/src/logs/log_service.ts b/yarn-project/pxe/src/logs/log_service.ts index f632741f190f..18dd7d2d2a55 100644 --- a/yarn-project/pxe/src/logs/log_service.ts +++ b/yarn-project/pxe/src/logs/log_service.ts @@ -7,7 +7,13 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { BlockHash, L2TipsProvider } from '@aztec/stdlib/block'; import type { CompleteAddress } from '@aztec/stdlib/contract'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; -import { AppTaggingSecret, type LogResult, SiloedTag, computeSharedTaggingSecret } from '@aztec/stdlib/logs'; +import { + AppTaggingSecret, + AppTaggingSecretKind, + type LogResult, + SiloedTag, + computeSharedTaggingSecret, +} from '@aztec/stdlib/logs'; import type { BlockHeader } from '@aztec/stdlib/tx'; import { @@ -222,10 +228,10 @@ export class LogService { /** * Computes the tagging secrets PXE can enumerate for a recipient: one per known sender (via ECDH) plus any - * pre-shared secret points registered directly for the recipient, each siloed to `contractAddress` and directed to - * `recipient`. These require knowing the recipient's address preimage and keys, so returns an empty array when those - * are unavailable. App-supplied secrets (e.g. handshake-derived) are handled separately by the caller and do not go - * through here. + * pre-shared secrets registered directly for the recipient. Each registered secret is scanned under the tag + * streams its kind can back. Deriving the sender-based secrets requires the recipient's address preimage and keys, + * so returns an empty array when those are unavailable. App-supplied secrets (e.g. derived from discovered + * handshakes) are handled separately by the caller and do not go through here. */ async #getPointDerivedSecrets(contractAddress: AztecAddress, recipient: AztecAddress): Promise { const recipientCompleteAddress = await this.addressStore.getCompleteAddress(recipient); @@ -237,11 +243,26 @@ export class LogService { } const recipientIvsk = await this.keyStore.getMasterIncomingViewingSecretKey(recipient); - const points = [ - ...(await this.#getSecretsForSenders(recipientCompleteAddress, recipientIvsk)), - ...(await this.taggingSecretSourcesStore.getSharedSecretsForRecipient(recipient)), - ]; - return Promise.all(points.map(secret => AppTaggingSecret.computeDirectional(secret, contractAddress, recipient))); + const [senderPoints, registeredSecrets] = await Promise.all([ + this.#getSecretsForSenders(recipientCompleteAddress, recipientIvsk), + this.taggingSecretSourcesStore.getSharedSecretsForRecipient(recipient), + ]); + return Promise.all([ + ...senderPoints.map(secret => AppTaggingSecret.computeDirectional(secret, contractAddress, recipient)), + ...registeredSecrets.flatMap(({ kind, secret }) => { + switch (kind) { + case 'arbitrary-secret': + return [AppTaggingSecret.computeDirectional(secret, contractAddress, recipient)]; + case 'handshake': + // A handshake-backed sender tags messages with the bare app-siloed secret, one tag domain per delivery + // mode. + return [ + AppTaggingSecret.computeAppSiloed(secret, contractAddress, AppTaggingSecretKind.UNCONSTRAINED), + AppTaggingSecret.computeAppSiloed(secret, contractAddress, AppTaggingSecretKind.CONSTRAINED), + ]; + } + }), + ]); } async #getSecretsForSenders( diff --git a/yarn-project/pxe/src/pxe.test.ts b/yarn-project/pxe/src/pxe.test.ts index 92a3ab96f260..0dbf2e1af7af 100644 --- a/yarn-project/pxe/src/pxe.test.ts +++ b/yarn-project/pxe/src/pxe.test.ts @@ -29,7 +29,8 @@ import { getContractClassFromArtifact, } from '@aztec/stdlib/contract'; import type { AztecNode, AztecNodeDebug, BlockResponse } from '@aztec/stdlib/interfaces/client'; -import { deriveKeys } from '@aztec/stdlib/keys'; +import { computeAddressSecret, deriveKeys } from '@aztec/stdlib/keys'; +import { deriveEcdhSharedSecretPoint } from '@aztec/stdlib/logs'; import { randomContractArtifact, randomContractInstanceWithAddress, @@ -42,7 +43,7 @@ import { mock } from 'jest-mock-extended'; import type { MockProxy } from 'jest-mock-extended/lib/Mock.js'; import type { PXEConfig } from './config/index.js'; -import { PXE, type PackedPrivateEvent, type TaggingSecretSource } from './pxe.js'; +import { PXE, type PackedPrivateEvent } from './pxe.js'; import { PrivateEventStore } from './storage/private_event_store/private_event_store.js'; describe('PXE', () => { @@ -153,65 +154,114 @@ describe('PXE', () => { }); it('lists registered senders and arbitrary secrets together', async () => { - const senderSource: TaggingSecretSource = { - kind: 'address-derived', - sender: (await CompleteAddress.random()).address, - }; - const secretSource: TaggingSecretSource = { - kind: 'arbitrary-secret', - recipient: (await CompleteAddress.random()).address, - secret: await Point.random(), - }; - - await pxe.registerTaggingSecretSource(senderSource); - await pxe.registerTaggingSecretSource(secretSource); - - expect(await pxe.getTaggingSecretSources()).toEqual(expect.arrayContaining([senderSource, secretSource])); + const sender = (await CompleteAddress.random()).address; + const recipient = (await CompleteAddress.random()).address; + const secret = await Point.random(); + + await pxe.registerTaggingSecretSource({ kind: 'address-derived', sender }); + await pxe.registerTaggingSecretSource({ kind: 'arbitrary-secret', recipient, secret }); + + expect(await pxe.getTaggingSecretSources()).toEqual( + expect.arrayContaining([ + { kind: 'address-derived', sender }, + { kind: 'arbitrary-secret', recipient, secret }, + ]), + ); }); it('filters tagging secret sources by kind', async () => { - const senderSource: TaggingSecretSource = { - kind: 'address-derived', - sender: (await CompleteAddress.random()).address, - }; - const secretSource: TaggingSecretSource = { - kind: 'arbitrary-secret', - recipient: (await CompleteAddress.random()).address, - secret: await Point.random(), - }; + const sender = (await CompleteAddress.random()).address; + const recipient = (await CompleteAddress.random()).address; + const secret = await Point.random(); - await pxe.registerTaggingSecretSource(senderSource); - await pxe.registerTaggingSecretSource(secretSource); + await pxe.registerTaggingSecretSource({ kind: 'address-derived', sender }); + await pxe.registerTaggingSecretSource({ kind: 'arbitrary-secret', recipient, secret }); const senders = await pxe.getTaggingSecretSources({ kind: 'address-derived' }); - expect(senders).toContainEqual(senderSource); - expect(senders).not.toContainEqual(secretSource); + expect(senders).toContainEqual({ kind: 'address-derived', sender }); + expect(senders).not.toContainEqual({ kind: 'arbitrary-secret', recipient, secret }); const secrets = await pxe.getTaggingSecretSources({ kind: 'arbitrary-secret' }); - expect(secrets).toContainEqual(secretSource); - expect(secrets).not.toContainEqual(senderSource); + expect(secrets).toContainEqual({ kind: 'arbitrary-secret', recipient, secret }); + expect(secrets).not.toContainEqual({ kind: 'address-derived', sender }); }); it('removes registered senders and arbitrary secrets', async () => { - const senderSource: TaggingSecretSource = { - kind: 'address-derived', - sender: (await CompleteAddress.random()).address, - }; - const secretSource: TaggingSecretSource = { - kind: 'arbitrary-secret', - recipient: (await CompleteAddress.random()).address, - secret: await Point.random(), - }; + const sender = (await CompleteAddress.random()).address; + const recipient = (await CompleteAddress.random()).address; + const secret = await Point.random(); - await pxe.registerTaggingSecretSource(senderSource); - await pxe.registerTaggingSecretSource(secretSource); + await pxe.registerTaggingSecretSource({ kind: 'address-derived', sender }); + await pxe.registerTaggingSecretSource({ kind: 'arbitrary-secret', recipient, secret }); - await pxe.removeTaggingSecretSource(senderSource); - await pxe.removeTaggingSecretSource(secretSource); + await pxe.removeTaggingSecretSource({ kind: 'address-derived', sender }); + await pxe.removeTaggingSecretSource({ kind: 'arbitrary-secret', recipient, secret }); const remaining = await pxe.getTaggingSecretSources(); - expect(remaining).not.toContainEqual(senderSource); - expect(remaining).not.toContainEqual(secretSource); + expect(remaining).not.toContainEqual({ kind: 'address-derived', sender }); + expect(remaining).not.toContainEqual({ kind: 'arbitrary-secret', recipient, secret }); + }); + + it('registers a handshake by ephemeral key and lists its derived shared secret', async () => { + const privacyKeys = await deriveKeys(Fr.random()); + const completeAddress = await pxe.registerAccount(privacyKeys, Fr.random()); + const recipient = completeAddress.address; + const ephPk = (await Point.random()).x; + + await pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk }); + + const addressSecret = await computeAddressSecret( + await completeAddress.getPreaddress(), + privacyKeys.masterIncomingViewingSecretKey, + ); + const expectedSecret = await deriveEcdhSharedSecretPoint(addressSecret, await Point.fromXAndSign(ephPk, true)); + expect(await pxe.getTaggingSecretSources({ kind: 'handshake' })).toContainEqual({ + kind: 'handshake', + recipient, + secret: expectedSecret, + }); + }); + + it('ignores a handshake registered twice for the same ephemeral key', async () => { + const { address: recipient } = await pxe.registerAccount(await deriveKeys(Fr.random()), Fr.random()); + const ephPk = (await Point.random()).x; + + await pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk }); + await pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk }); + + const secrets = await pxe.getTaggingSecretSources({ kind: 'handshake' }); + expect(secrets.filter(s => s.recipient.equals(recipient))).toHaveLength(1); + }); + + it('refuses to register a handshake for a recipient that is not an account', async () => { + const recipient = (await CompleteAddress.random()).address; + await expect( + pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk: (await Point.random()).x }), + ).rejects.toThrow(/not an account/); + }); + + it('refuses to register a handshake whose ephemeral key x-coordinate is not on the curve', async () => { + const { address: recipient } = await pxe.registerAccount(await deriveKeys(Fr.random()), Fr.random()); + // x = 3 is not a valid x-coordinate on the Grumpkin curve + const ephPk = new Fr(3); + await expect(pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk })).rejects.toThrow( + `Ephemeral public key x-coordinate ${ephPk} does not correspond to a Grumpkin curve point.`, + ); + }); + + it('removes a registered handshake by its listed secret', async () => { + const { address: recipient } = await pxe.registerAccount(await deriveKeys(Fr.random()), Fr.random()); + const ephPk = (await Point.random()).x; + + await pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk }); + + const [listed] = (await pxe.getTaggingSecretSources({ kind: 'handshake' })).filter(s => + s.recipient.equals(recipient), + ); + await pxe.removeTaggingSecretSource(listed); + + const secrets = await pxe.getTaggingSecretSources({ kind: 'handshake' }); + expect(secrets.filter(s => s.recipient.equals(recipient))).toHaveLength(0); }); it('successfully adds a contract', async () => { diff --git a/yarn-project/pxe/src/pxe.ts b/yarn-project/pxe/src/pxe.ts index 0e443ad9e45f..d27cc8b58803 100644 --- a/yarn-project/pxe/src/pxe.ts +++ b/yarn-project/pxe/src/pxe.ts @@ -36,6 +36,8 @@ import type { PrivateKernelExecutionProofOutput, PrivateKernelTailCircuitPublicInputs, } from '@aztec/stdlib/kernel'; +import { computeAddressSecret } from '@aztec/stdlib/keys'; +import { deriveEcdhSharedSecretPoint } from '@aztec/stdlib/logs'; import { BlockHeader, type ContractOverrides, @@ -87,7 +89,10 @@ import { openPxeStores } from './storage/open_pxe_stores.js'; import { PrivateEventStore } from './storage/private_event_store/private_event_store.js'; import { RecipientTaggingStore } from './storage/tagging_store/recipient_tagging_store.js'; import { SenderTaggingStore } from './storage/tagging_store/sender_tagging_store.js'; -import { TaggingSecretSourcesStore } from './storage/tagging_store/tagging_secret_sources_store.js'; +import { + type SharedSecretKind, + TaggingSecretSourcesStore, +} from './storage/tagging_store/tagging_secret_sources_store.js'; import { persistSenderTaggingIndexRangesForTx } from './tagging/index.js'; export type PackedPrivateEvent = InTx & { @@ -191,18 +196,33 @@ export type PXECreateArgs = { hooks?: ExecutionHooks; }; -/** - * A source from which PXE derives the tagging secrets it scans for to discover incoming private logs. - * - * - `address-derived`: derives a shared secret via ECDH from an external `sender` address against every account - * registered in this PXE (present and future), so registering one sender applies it to all of them. The address is - * not secret, so unlike `arbitrary-secret` it can be reused freely across recipients. - * - `arbitrary-secret`: a shared secret point provided directly, scoped to a single recipient. It bypasses ECDH, so it - * must not be reused across recipients (each would then be able to find the others' tags). - */ +/** A source from which PXE derives the tagging secrets it scans for to discover incoming private logs. */ export type TaggingSecretSource = + /** + * Derives a shared secret via ECDH from an external `sender` address against every account registered in this PXE + * (present and future), so registering one sender applies it to all of them. The address is not secret, so unlike + * `arbitrary-secret` it can be reused freely across recipients. + */ | { kind: 'address-derived'; sender: AztecAddress } - | { kind: 'arbitrary-secret'; recipient: AztecAddress; secret: Point }; + /** + * A shared secret point provided directly, scoped to a single recipient. It bypasses ECDH, so it must not be + * reused across recipients (each would then be able to find the others' tags). + */ + | { kind: 'arbitrary-secret'; recipient: AztecAddress; secret: Point } + /** + * A handshake known by the x-coordinate of its ephemeral public key (the value a recipient learns while authorizing + * an interactive handshake, which emits no discoverable announcement). The ephemeral key's y-coordinate is always + * positive by protocol construction, so the x-coordinate alone identifies it. + */ + | { kind: 'handshake'; recipient: AztecAddress; ephPk: Fr }; + +/** + * The registered form of a {@link TaggingSecretSource}. + */ +export type RegisteredTaggingSecretSource = + | Exclude + /** A handshake's resolved shared secret, derived at registration from the handshake's ephemeral key. */ + | { kind: 'handshake'; recipient: AztecAddress; secret: Point }; /** * Private eXecution Environment (PXE) is a library used by wallets to simulate private phase of transactions and to @@ -769,7 +789,14 @@ export class PXE { wasAdded = await this.#registerSender(source.sender); break; case 'arbitrary-secret': - wasAdded = await this.#registerArbitrarySecret(source.recipient, source.secret); + wasAdded = await this.#registerSharedSecret(source.recipient, 'arbitrary-secret', source.secret); + break; + case 'handshake': + wasAdded = await this.#registerSharedSecret( + source.recipient, + 'handshake', + await this.#deriveHandshakeSecret(source.recipient, source.ephPk), + ); break; default: { const _: never = source; @@ -784,9 +811,10 @@ export class PXE { } /** - * Removes a previously registered tagging secret source. Does nothing if it was not registered. + * Removes a previously registered tagging secret source, identified by its registered form (see + * {@link getTaggingSecretSources}). Does nothing if it was not registered. */ - public async removeTaggingSecretSource(source: TaggingSecretSource): Promise { + public async removeTaggingSecretSource(source: RegisteredTaggingSecretSource): Promise { switch (source.kind) { case 'address-derived': { const { sender } = source; @@ -798,13 +826,14 @@ export class PXE { ); break; } - case 'arbitrary-secret': { - const { recipient, secret } = source; - const wasRemoved = await this.taggingSecretSourcesStore.removeSharedSecret(recipient, secret); + case 'arbitrary-secret': + case 'handshake': { + const { kind, recipient, secret } = source; + const wasRemoved = await this.taggingSecretSourcesStore.removeSharedSecret(recipient, kind, secret); this.log.info( wasRemoved - ? `Removed shared secret for recipient:\n ${recipient.toString()}` - : `Shared secret not registered for recipient:\n ${recipient.toString()}`, + ? `Removed ${kind} shared secret for recipient:\n ${recipient.toString()}` + : `No ${kind} shared secret registered for recipient:\n ${recipient.toString()}`, ); break; } @@ -816,24 +845,24 @@ export class PXE { } /** - * Retrieves the tagging secret sources registered in this PXE. Without a filter it returns every source; pass - * `{ kind }` to narrow to a single variant. See {@link TaggingSecretSource}. + * Retrieves the tagging secret sources registered in this PXE, in their registered form. Without a filter it + * returns every source; pass `{ kind }` to narrow to a single variant. See {@link RegisteredTaggingSecretSource}. */ - public getTaggingSecretSources(filter: { + public getTaggingSecretSources(filter: { kind: K; - }): Promise[]>; - public getTaggingSecretSources(): Promise; + }): Promise[]>; + public getTaggingSecretSources(): Promise; public async getTaggingSecretSources(filter?: { - kind?: TaggingSecretSource['kind']; - }): Promise { + kind?: RegisteredTaggingSecretSource['kind']; + }): Promise { const [senders, secrets] = await Promise.all([ this.taggingSecretSourcesStore.getSenders(), this.taggingSecretSourcesStore.getAllSharedSecrets(), ]); - const sources: TaggingSecretSource[] = [ - ...senders.map((sender): TaggingSecretSource => ({ kind: 'address-derived', sender })), - ...secrets.map(({ recipient, secret }): TaggingSecretSource => ({ kind: 'arbitrary-secret', recipient, secret })), + const sources: RegisteredTaggingSecretSource[] = [ + ...senders.map((sender): RegisteredTaggingSecretSource => ({ kind: 'address-derived', sender })), + ...secrets.map(({ recipient, kind, secret }): RegisteredTaggingSecretSource => ({ kind, recipient, secret })), ]; return filter?.kind ? sources.filter(source => source.kind === filter.kind) : sources; @@ -860,8 +889,36 @@ export class PXE { return wasAdded; } - /** Registers a directly-provided shared secret scoped to a recipient. Returns whether it was newly added. */ - async #registerArbitrarySecret(recipient: AztecAddress, secret: Point): Promise { + /** + * Derives the shared secret of a handshake from its ephemeral public key: `S = addressSecret(recipient) * ephPk`, + * the same derivation the recipient-side scan uses for handshakes discovered onchain. The ephemeral key is + * reconstructed from its x-coordinate with a positive y, matching how the protocol generates it. + * + * @throws If `ephPk` is not the x-coordinate of a Grumpkin curve point, or if `recipient` is not an account of + * this PXE, since the derivation needs its keys. + */ + async #deriveHandshakeSecret(recipient: AztecAddress, ephPk: Fr): Promise { + let ephPkPoint: Point; + try { + ephPkPoint = await Point.fromXAndSign(ephPk, true); + } catch { + throw new Error(`Ephemeral public key x-coordinate ${ephPk} does not correspond to a Grumpkin curve point.`); + } + + const completeAddress = await this.addressStore.getCompleteAddress(recipient); + if (!completeAddress || !(await this.keyStore.hasAccount(recipient))) { + throw new Error( + `Recipient ${recipient} is not an account of this PXE. A handshake's shared secret can only be derived for an account whose keys are held.`, + ); + } + + const ivskM = await this.keyStore.getMasterIncomingViewingSecretKey(recipient); + const addressSecret = await computeAddressSecret(await completeAddress.getPreaddress(), ivskM); + return deriveEcdhSharedSecretPoint(addressSecret, ephPkPoint); + } + + /** Registers a resolved shared secret scoped to a recipient. Returns whether it was newly added. */ + async #registerSharedSecret(recipient: AztecAddress, kind: SharedSecretKind, secret: Point): Promise { if (!(await recipient.isValid())) { throw new Error( `Recipient ${recipient} is not valid: it does not correspond to a point on the Grumpkin curve. Cannot register a shared secret for it.`, @@ -872,11 +929,11 @@ export class PXE { throw new Error(`Shared secret ${secret} is not a valid non-zero point on the Grumpkin curve.`); } - const wasAdded = await this.taggingSecretSourcesStore.addSharedSecret(recipient, secret); + const wasAdded = await this.taggingSecretSourcesStore.addSharedSecret(recipient, kind, secret); this.log.info( wasAdded - ? `Added shared secret for recipient:\n ${recipient.toString()}` - : `Shared secret already registered for recipient:\n ${recipient.toString()}`, + ? `Added ${kind} shared secret for recipient:\n ${recipient.toString()}` + : `${kind} shared secret already registered for recipient:\n ${recipient.toString()}`, ); return wasAdded; } diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/TaggingSecretSourcesStore.json b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/TaggingSecretSourcesStore.json index 1c612022458a..61cebcc03e02 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/TaggingSecretSourcesStore.json +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/TaggingSecretSourcesStore.json @@ -16,15 +16,15 @@ "recipient_shared_secrets": [ { "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000007", - "value": "utf8:0x00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003" + "value": "{\"kind\":\"arbitrary-secret\",\"secret\":\"0x00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003\"}" }, { "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000007", - "value": "utf8:0x00000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000007" + "value": "{\"kind\":\"handshake\",\"secret\":\"0x00000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000007\"}" }, { "key": "utf8:0x000000000000000000000000000000000000000000000000000000000000000b", - "value": "utf8:0x000000000000000000000000000000000000000000000000000000000000000d0000000000000000000000000000000000000000000000000000000000000011" + "value": "{\"kind\":\"arbitrary-secret\",\"secret\":\"0x000000000000000000000000000000000000000000000000000000000000000d0000000000000000000000000000000000000000000000000000000000000011\"}" } ] } diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json index b4ad55b3b0ad..5afea8e276cf 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json @@ -1,5 +1,5 @@ { - "schemaVersion": 11, + "schemaVersion": 12, "stores": [ { "name": "capsules", diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts b/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts index 49734a0d4228..196f97b4cd67 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts @@ -553,20 +553,25 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ await taggingSecretSourcesStore.addSharedSecret( AztecAddress.fromBigIntUnsafe(7n), + 'arbitrary-secret', new Point(new Fr(2n), new Fr(3n)), ); await taggingSecretSourcesStore.addSharedSecret( AztecAddress.fromBigIntUnsafe(7n), + 'handshake', new Point(new Fr(5n), new Fr(7n)), ); await taggingSecretSourcesStore.addSharedSecret( AztecAddress.fromBigIntUnsafe(11n), + 'arbitrary-secret', new Point(new Fr(13n), new Fr(17n)), ); }, snapshotStore: async kvStore => ({ senders: await snapshotMap(kvStore.openMap('senders')), - recipient_shared_secrets: await snapshotMap(kvStore.openMultiMap('recipient_shared_secrets')), + recipient_shared_secrets: await snapshotMap( + kvStore.openMultiMap('recipient_shared_secrets'), + ), }), }, diff --git a/yarn-project/pxe/src/storage/metadata.ts b/yarn-project/pxe/src/storage/metadata.ts index 7a2e20431042..ca02f2aa5901 100644 --- a/yarn-project/pxe/src/storage/metadata.ts +++ b/yarn-project/pxe/src/storage/metadata.ts @@ -1 +1 @@ -export const PXE_DATA_SCHEMA_VERSION = 11; +export const PXE_DATA_SCHEMA_VERSION = 12; diff --git a/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.test.ts b/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.test.ts index 688ce7986a76..c62ed74bfbe0 100644 --- a/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.test.ts +++ b/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.test.ts @@ -26,21 +26,41 @@ describe('TaggingSecretSourcesStore', () => { }); describe('shared secrets', () => { - it('adds and retrieves shared secrets scoped to a recipient', async () => { + it('adds and retrieves an arbitrary secret scoped to a recipient', async () => { const recipient = await AztecAddress.random(); const secret = await Point.random(); - expect(await store.addSharedSecret(recipient, secret)).toBe(true); - expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([secret]); + expect(await store.addSharedSecret(recipient, 'arbitrary-secret', secret)).toBe(true); + expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([{ kind: 'arbitrary-secret', secret }]); + }); + + it('round-trips the kind of a handshake secret', async () => { + const recipient = await AztecAddress.random(); + const secret = await Point.random(); + + expect(await store.addSharedSecret(recipient, 'handshake', secret)).toBe(true); + expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([{ kind: 'handshake', secret }]); }); it('returns false when adding a duplicate secret for the same recipient', async () => { const recipient = await AztecAddress.random(); const secret = await Point.random(); - expect(await store.addSharedSecret(recipient, secret)).toBe(true); - expect(await store.addSharedSecret(recipient, secret)).toBe(false); - expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([secret]); + expect(await store.addSharedSecret(recipient, 'arbitrary-secret', secret)).toBe(true); + expect(await store.addSharedSecret(recipient, 'arbitrary-secret', secret)).toBe(false); + expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([{ kind: 'arbitrary-secret', secret }]); + }); + + it('rejects re-registering a secret under a different kind', async () => { + const recipient = await AztecAddress.random(); + const secret = await Point.random(); + + await store.addSharedSecret(recipient, 'handshake', secret); + + await expect(store.addSharedSecret(recipient, 'arbitrary-secret', secret)).rejects.toThrow( + `Secret already registered for recipient with kind 'handshake', cannot re-register it as 'arbitrary-secret'.`, + ); + expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([{ kind: 'handshake', secret }]); }); it('scopes secrets per recipient', async () => { @@ -49,11 +69,15 @@ describe('TaggingSecretSourcesStore', () => { const secretA = await Point.random(); const secretB = await Point.random(); - await store.addSharedSecret(recipientA, secretA); - await store.addSharedSecret(recipientB, secretB); + await store.addSharedSecret(recipientA, 'arbitrary-secret', secretA); + await store.addSharedSecret(recipientB, 'arbitrary-secret', secretB); - expect(await store.getSharedSecretsForRecipient(recipientA)).toEqual([secretA]); - expect(await store.getSharedSecretsForRecipient(recipientB)).toEqual([secretB]); + expect(await store.getSharedSecretsForRecipient(recipientA)).toEqual([ + { kind: 'arbitrary-secret', secret: secretA }, + ]); + expect(await store.getSharedSecretsForRecipient(recipientB)).toEqual([ + { kind: 'arbitrary-secret', secret: secretB }, + ]); }); it('lists every shared secret across recipients', async () => { @@ -63,18 +87,18 @@ describe('TaggingSecretSourcesStore', () => { const secretA2 = await Point.random(); const secretB = await Point.random(); - await store.addSharedSecret(recipientA, secretA1); - await store.addSharedSecret(recipientA, secretA2); - await store.addSharedSecret(recipientB, secretB); + await store.addSharedSecret(recipientA, 'arbitrary-secret', secretA1); + await store.addSharedSecret(recipientA, 'handshake', secretA2); + await store.addSharedSecret(recipientB, 'arbitrary-secret', secretB); const all = await store.getAllSharedSecrets(); expect(all).toHaveLength(3); expect(all).toEqual( expect.arrayContaining([ - { recipient: recipientA, secret: secretA1 }, - { recipient: recipientA, secret: secretA2 }, - { recipient: recipientB, secret: secretB }, + { recipient: recipientA, kind: 'arbitrary-secret', secret: secretA1 }, + { recipient: recipientA, kind: 'handshake', secret: secretA2 }, + { recipient: recipientB, kind: 'arbitrary-secret', secret: secretB }, ]), ); }); @@ -87,9 +111,9 @@ describe('TaggingSecretSourcesStore', () => { const recipient = await AztecAddress.random(); const secret = await Point.random(); - await store.addSharedSecret(recipient, secret); + await store.addSharedSecret(recipient, 'arbitrary-secret', secret); - expect(await store.removeSharedSecret(recipient, secret)).toBe(true); + expect(await store.removeSharedSecret(recipient, 'arbitrary-secret', secret)).toBe(true); expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([]); }); @@ -97,7 +121,17 @@ describe('TaggingSecretSourcesStore', () => { const recipient = await AztecAddress.random(); const secret = await Point.random(); - expect(await store.removeSharedSecret(recipient, secret)).toBe(false); + expect(await store.removeSharedSecret(recipient, 'arbitrary-secret', secret)).toBe(false); + }); + + it('does not remove a secret when the kind does not match', async () => { + const recipient = await AztecAddress.random(); + const secret = await Point.random(); + + await store.addSharedSecret(recipient, 'handshake', secret); + + expect(await store.removeSharedSecret(recipient, 'arbitrary-secret', secret)).toBe(false); + expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([{ kind: 'handshake', secret }]); }); it('keeps senders and shared secrets separate', async () => { @@ -106,10 +140,10 @@ describe('TaggingSecretSourcesStore', () => { const secret = await Point.random(); await store.addSender(sender); - await store.addSharedSecret(recipient, secret); + await store.addSharedSecret(recipient, 'arbitrary-secret', secret); expect(await store.getSenders()).toEqual([sender]); - expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([secret]); + expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([{ kind: 'arbitrary-secret', secret }]); }); }); }); diff --git a/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.ts b/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.ts index 5e228340448a..b30ab04a4465 100644 --- a/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.ts +++ b/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.ts @@ -3,6 +3,14 @@ import { toArray } from '@aztec/foundation/iterable'; import type { AztecAsyncKVStore, AztecAsyncMap, AztecAsyncMultiMap } from '@aztec/kv-store'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; +/** + * The kinds of shared secret an entry can hold. + */ +export type SharedSecretKind = 'arbitrary-secret' | 'handshake'; + +/** A shared secret registered for a recipient, with the kind that determines which tag streams it backs. */ +export type SharedSecret = { kind: SharedSecretKind; secret: Point }; + /** * Stores the sources from which directional app tagging secrets are derived during recipient log synchronization. * @@ -13,12 +21,13 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; * - Pre-shared tagging secrets: shared secret points registered directly, bypassing ECDH. These are scoped to a * specific recipient, since the derivation of the directional app tagging secret does not require any secret * recipient data: given the original secret anyone can derive a recipient's app-siloed directional tagging secret, - * and so these must not be reused to preserve privacy. + * and so these must not be reused across recipients to preserve privacy. Each carries the {@link SharedSecretKind} + * it was registered through, which determines the tag streams it is scanned under. */ export class TaggingSecretSourcesStore { #store: AztecAsyncKVStore; #senders: AztecAsyncMap; - #sharedSecretsByRecipient: AztecAsyncMultiMap; + #sharedSecretsByRecipient: AztecAsyncMultiMap; constructor(store: AztecAsyncKVStore) { this.#store = store; @@ -61,52 +70,57 @@ export class TaggingSecretSourcesStore { * Registers a pre-shared tagging secret scoped to a recipient. * @returns true if the secret was newly added, false if it was already registered for that recipient. */ - addSharedSecret(recipient: AztecAddress, secret: Point): Promise { + addSharedSecret(recipient: AztecAddress, kind: SharedSecretKind, secret: Point): Promise { return this.#store.transactionAsync(async () => { const secretStr = secret.toString(); // MultiMap.set silently ignores an identical (key, value), so we scan to report whether this is a new secret. for await (const existing of this.#sharedSecretsByRecipient.getValuesAsync(recipient.toString())) { - if (existing === secretStr) { + if (existing.secret === secretStr) { + if (existing.kind !== kind) { + throw new Error( + `Secret already registered for recipient with kind '${existing.kind}', cannot re-register it as '${kind}'.`, + ); + } return false; } } - await this.#sharedSecretsByRecipient.set(recipient.toString(), secretStr); + await this.#sharedSecretsByRecipient.set(recipient.toString(), { kind, secret: secretStr }); return true; }); } /** Returns the pre-shared tagging secrets registered for a given recipient. */ - getSharedSecretsForRecipient(recipient: AztecAddress): Promise { + getSharedSecretsForRecipient(recipient: AztecAddress): Promise { return this.#store.transactionAsync(async () => { - return (await toArray(this.#sharedSecretsByRecipient.getValuesAsync(recipient.toString()))).map(secret => - Point.fromString(secret), + return (await toArray(this.#sharedSecretsByRecipient.getValuesAsync(recipient.toString()))).map( + deserializeSharedSecret, ); }); } /** Returns every registered pre-shared tagging secret, each paired with the recipient it is scoped to. */ - getAllSharedSecrets(): Promise<{ recipient: AztecAddress; secret: Point }[]> { + getAllSharedSecrets(): Promise<{ recipient: AztecAddress; kind: SharedSecretKind; secret: Point }[]> { return this.#store.transactionAsync(async () => { const entries = await toArray(this.#sharedSecretsByRecipient.entriesAsync()); - return entries.map(([recipient, secret]) => ({ + return entries.map(([recipient, entry]) => ({ recipient: AztecAddress.fromStringUnsafe(recipient), - secret: Point.fromString(secret), + ...deserializeSharedSecret(entry), })); }); } /** - * Removes a pre-shared tagging secret scoped to a recipient. + * Removes a pre-shared tagging secret scoped to a recipient. Both the secret and its kind must match. * @returns true if the secret was registered and removed, false if it was not registered for that recipient. */ - removeSharedSecret(recipient: AztecAddress, secret: Point): Promise { + removeSharedSecret(recipient: AztecAddress, kind: SharedSecretKind, secret: Point): Promise { return this.#store.transactionAsync(async () => { const secretStr = secret.toString(); for await (const existing of this.#sharedSecretsByRecipient.getValuesAsync(recipient.toString())) { - if (existing === secretStr) { - await this.#sharedSecretsByRecipient.deleteValue(recipient.toString(), secretStr); + if (existing.kind === kind && existing.secret === secretStr) { + await this.#sharedSecretsByRecipient.deleteValue(recipient.toString(), existing); return true; } } @@ -115,3 +129,10 @@ export class TaggingSecretSourcesStore { }); } } + +/** The wire form a shared secret entry is persisted as. */ +type StoredSharedSecret = { kind: SharedSecretKind; secret: string }; + +function deserializeSharedSecret(entry: StoredSharedSecret): SharedSecret { + return { kind: entry.kind, secret: Point.fromString(entry.secret) }; +} diff --git a/yarn-project/stdlib/src/logs/app_tagging_secret.ts b/yarn-project/stdlib/src/logs/app_tagging_secret.ts index 71c1a38a93e8..ad263cee3b06 100644 --- a/yarn-project/stdlib/src/logs/app_tagging_secret.ts +++ b/yarn-project/stdlib/src/logs/app_tagging_secret.ts @@ -56,6 +56,18 @@ export class AppTaggingSecret { return new AppTaggingSecret(directionalAppTaggingSecret, app); } + /** + * Derives the bare app-siloed tagging secret from a shared secret point via {@link appSiloEcdhSharedSecretPoint}, + * under the given delivery-mode kind. + */ + static async computeAppSiloed( + taggingSecretPoint: Point, + app: AztecAddress, + kind: AppTaggingSecretKind, + ): Promise { + return new AppTaggingSecret(await appSiloEcdhSharedSecretPoint(taggingSecretPoint, app), app, kind); + } + /** * Derives the tagging secret for `(externalAddress, recipient, app)` by performing an ECDH key exchange against * `externalAddress` to obtain the shared point, then siloing and directing it via {@link computeDirectional}. diff --git a/yarn-project/stdlib/src/logs/shared_secret_derivation.ts b/yarn-project/stdlib/src/logs/shared_secret_derivation.ts index 48c16284a683..4edf554b8c78 100644 --- a/yarn-project/stdlib/src/logs/shared_secret_derivation.ts +++ b/yarn-project/stdlib/src/logs/shared_secret_derivation.ts @@ -8,8 +8,22 @@ import type { AztecAddress } from '../aztec-address/index.js'; import type { PublicKey } from '../keys/public_key.js'; /** - * Derives an app-siloed ECDH shared secret from keys: ECDHs `S = secretKey * publicKey`, then app-silos it via - * {@link appSiloEcdhSharedSecretPoint}. + * Derives the raw ECDH shared secret point `S = secretKey * publicKey`. + * + * @throws If the publicKey is zero. + */ +export function deriveEcdhSharedSecretPoint(secretKey: GrumpkinScalar, publicKey: PublicKey): Promise { + if (publicKey.isZero()) { + throw new Error( + `Attempting to derive a shared secret with a zero public key. You have probably passed a zero public key in your Noir code somewhere thinking that the note won't be broadcast... but it was.`, + ); + } + return Grumpkin.mul(publicKey, secretKey); +} + +/** + * Derives an app-siloed ECDH shared secret from keys: ECDHs `S = secretKey * publicKey` via + * {@link deriveEcdhSharedSecretPoint}, then app-silos it via {@link appSiloEcdhSharedSecretPoint}. * * @param secretKey - The secret key used to derive shared secret. * @param publicKey - The public key used to derive shared secret. @@ -22,12 +36,7 @@ export async function appSiloEcdhSharedSecret( publicKey: PublicKey, contractAddress: AztecAddress, ): Promise { - if (publicKey.isZero()) { - throw new Error( - `Attempting to derive a shared secret with a zero public key. You have probably passed a zero public key in your Noir code somewhere thinking that the note won't be broadcast... but it was.`, - ); - } - const rawSharedSecret = await Grumpkin.mul(publicKey, secretKey); + const rawSharedSecret = await deriveEcdhSharedSecretPoint(secretKey, publicKey); return appSiloEcdhSharedSecretPoint(rawSharedSecret, contractAddress); }