From 1862b7b2ff881e5a1a367b0d5001501c5c5181a6 Mon Sep 17 00:00:00 2001 From: Tony D'Addeo Date: Sat, 16 May 2026 12:36:39 -0500 Subject: [PATCH 1/2] feat(tempo): support P256 session vouchers --- .changeset/p256-session-vouchers.md | 5 ++ src/tempo/client/ChannelOps.ts | 6 ++ src/tempo/client/Session.test.ts | 48 +++++++++++- src/tempo/client/Session.ts | 54 +++++++++---- src/tempo/client/SessionManager.ts | 5 +- src/tempo/session/Voucher.test.ts | 87 +++++++++++++++++++++ src/tempo/session/Voucher.ts | 116 +++++++++++++++++----------- 7 files changed, 257 insertions(+), 64 deletions(-) create mode 100644 .changeset/p256-session-vouchers.md diff --git a/.changeset/p256-session-vouchers.md b/.changeset/p256-session-vouchers.md new file mode 100644 index 00000000..1d87356b --- /dev/null +++ b/.changeset/p256-session-vouchers.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added direct P256/WebCrypto session voucher signing and TIP-1020 voucher verification. diff --git a/src/tempo/client/ChannelOps.ts b/src/tempo/client/ChannelOps.ts index 380680a9..7939e63e 100644 --- a/src/tempo/client/ChannelOps.ts +++ b/src/tempo/client/ChannelOps.ts @@ -77,6 +77,7 @@ export async function createVoucherPayload( escrowContract: Address, chainId: number, authorizedSigner?: Address | undefined, + voucherSigner?: viem_Account | undefined, ): Promise { const signature = await signVoucher( client, @@ -85,6 +86,7 @@ export async function createVoucherPayload( escrowContract, chainId, authorizedSigner, + voucherSigner, ) return { action: 'voucher', @@ -102,6 +104,7 @@ export async function createClosePayload( escrowContract: Address, chainId: number, authorizedSigner?: Address | undefined, + voucherSigner?: viem_Account | undefined, ): Promise { const signature = await signVoucher( client, @@ -110,6 +113,7 @@ export async function createClosePayload( escrowContract, chainId, authorizedSigner, + voucherSigner, ) return { action: 'close', @@ -124,6 +128,7 @@ export async function createOpenPayload( account: viem_Account, options: { authorizedSigner?: Address | undefined + voucherSigner?: viem_Account | undefined escrowContract: Address payee: Address currency: Address @@ -177,6 +182,7 @@ export async function createOpenPayload( escrowContract, chainId, options.authorizedSigner, + options.voucherSigner, ) return { diff --git a/src/tempo/client/Session.test.ts b/src/tempo/client/Session.test.ts index f8475f87..ee7df4c6 100644 --- a/src/tempo/client/Session.test.ts +++ b/src/tempo/client/Session.test.ts @@ -1,6 +1,7 @@ +import { SignatureEnvelope } from 'ox/tempo' import { type Address, createClient, decodeFunctionData, erc20Abi, type Hex, http } from 'viem' import { privateKeyToAccount } from 'viem/accounts' -import { Addresses, Transaction } from 'viem/tempo' +import { Account as TempoAccount, Addresses, Transaction, WebCryptoP256 } from 'viem/tempo' import { beforeAll, describe, expect, test } from 'vp/test' import { nodeEnv } from '~test/config.js' import { deployEscrow, openChannel } from '~test/tempo/session.js' @@ -13,6 +14,7 @@ import * as Credential from '../../Credential.js' import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js' import { escrowAbi } from '../session/Chain.js' import type { SessionCredentialPayload } from '../session/Types.js' +import { verifyVoucher } from '../session/Voucher.js' import { session } from './Session.js' function deserializePayload(result: string) { @@ -184,6 +186,50 @@ describe('session (pure)', () => { expect(cred.source).toBe(`did:pkh:eip155:42431:${pureAccount.address}`) }) + test('manual open signs voucher with P256 voucher signer', async () => { + const keyPair = await WebCryptoP256.createKeyPair() + const voucherSigner = TempoAccount.fromWebCryptoP256(keyPair) + const method = session({ + getClient: () => pureClient, + account: pureAccount, + voucherSigner, + }) + + const result = await method.createCredential({ + challenge: makeChallenge(), + context: { + action: 'open', + channelId, + cumulativeAmount: '5', + transaction: '0xdeadbeef', + }, + }) + + const cred = deserializePayload(result) + expect(cred.payload.action).toBe('open') + if (cred.payload.action !== 'open') throw new Error('unexpected action') + expect(cred.payload.authorizedSigner).toBe(voucherSigner.address) + + const envelope = SignatureEnvelope.from( + cred.payload.signature as SignatureEnvelope.Serialized, + ) + expect(envelope.type).toBe('p256') + expect(cred.payload.signature.length).toBe(262) + + const isValid = await verifyVoucher( + escrowAddress, + 42431, + { + channelId, + cumulativeAmount: 5_000_000n, + signature: cred.payload.signature, + }, + voucherSigner.address, + ) + expect(isValid).toBe(true) + expect(cred.source).toBe(`did:pkh:eip155:42431:${pureAccount.address}`) + }) + test('manual close produces valid credential', async () => { const method = session({ getClient: () => pureClient, account: pureAccount }) diff --git a/src/tempo/client/Session.ts b/src/tempo/client/Session.ts index 41b4f540..ebdd2222 100644 --- a/src/tempo/client/Session.ts +++ b/src/tempo/client/Session.ts @@ -28,6 +28,7 @@ export const sessionContextSchema = z.object({ cumulativeAmountRaw: z.optional(z.string()), transaction: z.optional(z.string()), authorizedSigner: z.optional(z.string()), + voucherSigner: z.optional(z.custom()), additionalDeposit: z.optional(z.amount()), additionalDepositRaw: z.optional(z.string()), depositRaw: z.optional(z.string()), @@ -79,9 +80,25 @@ export function session(parameters: session.Parameters = {}) { rpcUrl: defaults.rpcUrl, }) const getAccount = Account.getResolver({ account: parameters.account }) - const getAuthorizedSigner = (account: viem_Account) => - parameters.authorizedSigner ?? - (account as unknown as { accessKeyAddress?: Address }).accessKeyAddress + function getVoucherSigning(account: viem_Account, context?: SessionContext | undefined) { + const contextVoucherSigner = context?.voucherSigner + const voucherSigner = contextVoucherSigner ?? parameters.voucherSigner + const authorizedSigner = + (context?.authorizedSigner as Address | undefined) ?? + contextVoucherSigner?.address ?? + parameters.authorizedSigner ?? + parameters.voucherSigner?.address ?? + (account as unknown as { accessKeyAddress?: Address }).accessKeyAddress + + if ( + authorizedSigner && + voucherSigner && + authorizedSigner.toLowerCase() !== voucherSigner.address.toLowerCase() + ) + throw new Error('authorizedSigner must match voucherSigner.address') + + return { authorizedSigner, voucherSigner } + } const maxDeposit = parameters.maxDeposit !== undefined ? parseUnits(parameters.maxDeposit, decimals) : undefined @@ -141,7 +158,7 @@ export function session(parameters: session.Parameters = {}) { ) })() - const authorizedSigner = getAuthorizedSigner(account) + const voucherSigning = getVoucherSigning(account, context) const key = channelKey(payee, currency, escrowContract) let entry = channels.get(key) @@ -186,12 +203,14 @@ export function session(parameters: session.Parameters = {}) { entry.cumulativeAmount, escrowContract, chainId, - authorizedSigner, + voucherSigning.authorizedSigner, + voucherSigning.voucherSigner, ) notifyUpdate(entry) } else { const result = await createOpenPayload(client, account, { - authorizedSigner, + authorizedSigner: voucherSigning.authorizedSigner, + voucherSigner: voucherSigning.voucherSigner, escrowContract, payee, currency, @@ -222,12 +241,8 @@ export function session(parameters: session.Parameters = {}) { const client = await getClient({ chainId }) const action = context.action! - const { - channelId: channelIdRaw, - transaction, - authorizedSigner: contextAuthorizedSigner, - } = context - const authorizedSigner = (contextAuthorizedSigner as Address) ?? getAuthorizedSigner(account) + const { channelId: channelIdRaw, transaction } = context + const voucherSigning = getVoucherSigning(account, context) const channelId = channelIdRaw as Hex.Hex const cumulativeAmount = context.cumulativeAmountRaw ? BigInt(context.cumulativeAmountRaw) @@ -256,14 +271,15 @@ export function session(parameters: session.Parameters = {}) { { channelId, cumulativeAmount }, escrowContract, chainId, - authorizedSigner, + voucherSigning.authorizedSigner, + voucherSigning.voucherSigner, ) payload = { action: 'open', type: 'transaction', channelId, transaction: transaction as Hex.Hex, - authorizedSigner: authorizedSigner ?? account.address, + authorizedSigner: voucherSigning.authorizedSigner ?? account.address, cumulativeAmount: cumulativeAmount.toString(), signature, } @@ -293,7 +309,8 @@ export function session(parameters: session.Parameters = {}) { cumulativeAmount, escrowContract, chainId, - authorizedSigner, + voucherSigning.authorizedSigner, + voucherSigning.voucherSigner, ) const key = channelIdToKey.get(channelId) if (key) { @@ -316,7 +333,8 @@ export function session(parameters: session.Parameters = {}) { { channelId, cumulativeAmount }, escrowContract, chainId, - authorizedSigner, + voucherSigning.authorizedSigner, + voucherSigning.voucherSigner, ) payload = { action: 'close', @@ -364,8 +382,10 @@ export function session(parameters: session.Parameters = {}) { export declare namespace session { type Parameters = Account.getResolver.Parameters & Client.getResolver.Parameters & { - /** Address authorized to sign vouchers. Defaults to the account address. Use when a separate access key (e.g. secp256k1) signs vouchers while the root account funds the channel. */ + /** Address authorized to sign vouchers. Defaults to the voucher signer address, access key address, or account address. */ authorizedSigner?: Address | undefined + /** Account that signs voucher digests directly. Use for delegated P256/WebCrypto voucher signing while the root account funds the channel. */ + voucherSigner?: viem_Account | undefined /** Token decimals for parsing human-readable amounts (default: 6). */ decimals?: number | undefined /** Initial deposit amount in human-readable units (e.g. "10" for 10 tokens). When set, the method handles the full channel lifecycle (open, voucher, cumulative tracking) automatically. */ diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index dac4a8fb..25c04c89 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -1,5 +1,5 @@ import type { Hex } from 'ox' -import { parseUnits, type Address } from 'viem' +import { parseUnits, type Account as viem_Account, type Address } from 'viem' import * as Challenge from '../../Challenge.js' import * as Fetch from '../../client/internal/Fetch.js' @@ -125,6 +125,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa const method = sessionPlugin({ account: parameters.account, authorizedSigner: parameters.authorizedSigner, + voucherSigner: parameters.voucherSigner, getClient: parameters.client ? () => parameters.client! : parameters.getClient, escrowContract: parameters.escrowContract, decimals: parameters.decimals, @@ -855,6 +856,8 @@ export declare namespace sessionManager { Client.getResolver.Parameters & { /** Address authorized to sign vouchers. Defaults to the account address. */ authorizedSigner?: Address | undefined + /** Account that signs voucher digests directly. Use for delegated P256/WebCrypto voucher signing. */ + voucherSigner?: viem_Account | undefined /** Viem client instance. Shorthand for `getClient: () => client`. */ client?: import('viem').Client | undefined /** Token decimals used to convert `maxDeposit` to raw units. Defaults to `6`. */ diff --git a/src/tempo/session/Voucher.test.ts b/src/tempo/session/Voucher.test.ts index 0e82ae87..d6d2c777 100644 --- a/src/tempo/session/Voucher.test.ts +++ b/src/tempo/session/Voucher.test.ts @@ -1,5 +1,7 @@ +import { SignatureEnvelope } from 'ox/tempo' import { createClient, http } from 'viem' import { privateKeyToAccount } from 'viem/accounts' +import { Account as TempoAccount, WebCryptoP256 } from 'viem/tempo' import { describe, expect, test } from 'vp/test' import { parseVoucherFromPayload, signVoucher, verifyVoucher } from './Voucher.js' @@ -39,6 +41,91 @@ describe('Voucher', () => { expect(isValid).toBe(true) }) + test('signVoucher and verifyVoucher round-trip with WebCrypto P256', async () => { + const keyPair = await WebCryptoP256.createKeyPair() + const p256Account = TempoAccount.fromWebCryptoP256(keyPair) + const p256Client = createClient({ + account: p256Account, + transport: http('http://127.0.0.1'), + }) + + const signature = await signVoucher( + p256Client, + p256Account, + { channelId, cumulativeAmount }, + escrowContract, + chainId, + ) + const envelope = SignatureEnvelope.from(signature as SignatureEnvelope.Serialized) + + expect(envelope.type).toBe('p256') + if (envelope.type !== 'p256') throw new Error('unexpected signature type') + expect(envelope.prehash).toBe(true) + expect(signature.length).toBe(262) + + const isValid = await verifyVoucher( + escrowContract, + chainId, + { channelId, cumulativeAmount, signature }, + p256Account.address, + ) + expect(isValid).toBe(true) + }) + + test('verifyVoucher rejects keychain envelopes', async () => { + const keyPair = await WebCryptoP256.createKeyPair() + const p256Account = TempoAccount.fromWebCryptoP256(keyPair) + const p256Client = createClient({ + account: p256Account, + transport: http('http://127.0.0.1'), + }) + const signature = await signVoucher( + p256Client, + p256Account, + { channelId, cumulativeAmount }, + escrowContract, + chainId, + ) + const keychainSignature = SignatureEnvelope.serialize({ + type: 'keychain', + version: 'v2', + userAddress: account.address, + inner: SignatureEnvelope.from(signature as SignatureEnvelope.Serialized), + }) + + const isValid = await verifyVoucher( + escrowContract, + chainId, + { channelId, cumulativeAmount, signature: keychainSignature }, + p256Account.address, + ) + expect(isValid).toBe(false) + }) + + test('signVoucher rejects keychain signer accounts', async () => { + const accessKey = TempoAccount.fromSecp256k1( + '0x59c6995e998f97a5a0044966f09453863d462d2b3f1446a99f0a3d7b5d0f5a0d', + { access: account }, + ) + const accessKeyClient = createClient({ + account: accessKey, + transport: http('http://127.0.0.1'), + }) + + await expect( + signVoucher( + accessKeyClient, + accessKey, + { channelId, cumulativeAmount }, + escrowContract, + chainId, + accessKey.accessKeyAddress, + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Session vouchers must be signed directly by authorizedSigner; pass a direct voucherSigner instead of a keychain account.]`, + ) + }) + test('verifyVoucher rejects wrong signer', async () => { const signature = await signVoucher( client, diff --git a/src/tempo/session/Voucher.ts b/src/tempo/session/Voucher.ts index 8b9f51ed..9b8a0a4f 100644 --- a/src/tempo/session/Voucher.ts +++ b/src/tempo/session/Voucher.ts @@ -1,10 +1,9 @@ -import { type Address, Signature } from 'ox' +import type { Address } from 'ox' import { SignatureEnvelope } from 'ox/tempo' import type { Account, Client, Hex } from 'viem' -import { recoverTypedDataAddress } from 'viem' +import { hashTypedData } from 'viem' import { signTypedData } from 'viem/actions' -import * as TempoAddress from '../internal/address.js' import type { SignedVoucher, Voucher } from './Types.js' /** Must match the on-chain TempoStreamChannel DOMAIN_SEPARATOR name. */ @@ -35,48 +34,92 @@ const voucherTypes = { ], } as const -/** - * Sign a voucher with an account. - */ -export async function signVoucher( +function getVoucherMessage(message: Voucher) { + return { + channelId: message.channelId, + cumulativeAmount: message.cumulativeAmount, + } +} + +function getVoucherDigest(escrowContract: Address.Address, chainId: number, message: Voucher) { + return hashTypedData({ + domain: getVoucherDomain(escrowContract, chainId), + types: voucherTypes, + primaryType: 'Voucher', + message: getVoucherMessage(message), + }) +} + +async function signVoucherDigest( client: Client, account: Account, message: Voucher, escrowContract: Address.Address, chainId: number, - authorizedSigner?: Address.Address | undefined, ): Promise { - const signature = await signTypedData(client, { + const sign = (account as { sign?: ((parameters: { hash: Hex }) => Promise) | undefined }) + .sign + if (sign) return sign({ hash: getVoucherDigest(escrowContract, chainId, message) }) + + return signTypedData(client, { account, domain: getVoucherDomain(escrowContract, chainId), types: voucherTypes, primaryType: 'Voucher', - message: { - channelId: message.channelId, - cumulativeAmount: message.cumulativeAmount, - }, + message: getVoucherMessage(message), }) +} + +function normalizeVoucherSignature(signature: Hex): Hex { + try { + const envelope = SignatureEnvelope.from(signature as SignatureEnvelope.Serialized) + + if (envelope.type === 'keychain') + throw new Error( + 'Session vouchers must be signed directly by authorizedSigner; pass a direct voucherSigner instead of a keychain account.', + ) - // When a separate authorizedSigner is used (e.g. access key), unwrap the - // keychain envelope — the escrow contract verifies raw ECDSA signatures - // against authorizedSigner, not keychain-wrapped ones. - // TODO: when TIP-1020 is implemented, we can remove this. - if (authorizedSigner) { - try { - const envelope = SignatureEnvelope.from(signature as SignatureEnvelope.Serialized) - if (envelope.type === 'keychain' && envelope.inner.type === 'secp256k1') - return Signature.toHex(envelope.inner.signature) - } catch {} + // Tempo local accounts may append signature-envelope magic bytes for RPC + // routing. Voucher signatures are direct TIP-1020 envelopes. + return SignatureEnvelope.serialize(envelope) + } catch (error) { + if (error instanceof Error && error.message.startsWith('Session vouchers must be signed')) + throw error } return signature } +/** + * Sign a voucher with an account. + */ +export async function signVoucher( + client: Client, + account: Account, + message: Voucher, + escrowContract: Address.Address, + chainId: number, + authorizedSigner?: Address.Address | undefined, + voucherSigner?: Account | undefined, +): Promise { + const signer = voucherSigner ?? account + if ((signer as { accessKeyAddress?: Address.Address }).accessKeyAddress) + throw new Error( + 'Session vouchers must be signed directly by authorizedSigner; pass a direct voucherSigner instead of a keychain account.', + ) + if (authorizedSigner && signer.address.toLowerCase() !== authorizedSigner.toLowerCase()) + throw new Error('authorizedSigner must match voucher signer address') + + const signature = await signVoucherDigest(client, signer, message, escrowContract, chainId) + + return normalizeVoucherSignature(signature) +} + /** * Verify a voucher signature matches the expected signer. * - * Only accepts raw secp256k1 signatures — the escrow contract verifies - * via ecrecover. Keychain, p256, and webAuthn signatures are rejected. + * Accepts direct TIP-1020 signatures. Keychain envelopes are rejected because + * direct voucher verification checks `authorizedSigner`, not wrapper metadata. */ export async function verifyVoucher( escrowContract: Address.Address, @@ -85,31 +128,14 @@ export async function verifyVoucher( expectedSigner: Address.Address, ): Promise { try { - const domain = getVoucherDomain(escrowContract, chainId) - const message = { - channelId: voucher.channelId, - cumulativeAmount: voucher.cumulativeAmount, - } - const envelope = SignatureEnvelope.from(voucher.signature) - // Reject keychain signatures — the escrow contract verifies raw ECDSA - // signatures against authorizedSigner, not keychain-wrapped ones. if (envelope.type === 'keychain') return false - // Reject non-secp256k1 signatures (p256, webAuthn) — the escrow contract - // only supports ecrecover-based verification. - // TODO: remove this once TIP-1020 is implemented - if (envelope.type !== 'secp256k1') return false - - const signer = await recoverTypedDataAddress({ - domain, - types: voucherTypes, - primaryType: 'Voucher', - message, - signature: voucher.signature, + return SignatureEnvelope.verify(envelope, { + address: expectedSigner, + payload: getVoucherDigest(escrowContract, chainId, voucher), }) - return TempoAddress.isEqual(signer, expectedSigner) } catch { return false } From 617ac5a658c0e0a679623a01ba74db47555f94f0 Mon Sep 17 00:00:00 2001 From: Tony D'Addeo Date: Thu, 21 May 2026 12:41:08 -0500 Subject: [PATCH 2/2] feat(tempo): add direct session voucher signing --- .changeset/p256-session-vouchers.md | 5 - src/tempo/client/ChannelOps.test.ts | 38 ++++++-- src/tempo/client/ChannelOps.ts | 30 ++++-- src/tempo/client/Session.test.ts | 78 ++++++++++++++- src/tempo/client/Session.ts | 49 ++++------ src/tempo/client/SessionManager.ts | 5 +- src/tempo/internal/account.ts | 13 +++ src/tempo/session/Voucher.test.ts | 146 ++++++++++++++++++++++++++-- src/tempo/session/Voucher.ts | 75 +++++++++----- 9 files changed, 345 insertions(+), 94 deletions(-) delete mode 100644 .changeset/p256-session-vouchers.md diff --git a/.changeset/p256-session-vouchers.md b/.changeset/p256-session-vouchers.md deleted file mode 100644 index 1d87356b..00000000 --- a/.changeset/p256-session-vouchers.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'mppx': patch ---- - -Added direct P256/WebCrypto session voucher signing and TIP-1020 voucher verification. diff --git a/src/tempo/client/ChannelOps.test.ts b/src/tempo/client/ChannelOps.test.ts index 75fffbed..3ae02906 100644 --- a/src/tempo/client/ChannelOps.test.ts +++ b/src/tempo/client/ChannelOps.test.ts @@ -1,7 +1,7 @@ import { Hex } from 'ox' import { type Address, createClient } from 'viem' import { privateKeyToAccount } from 'viem/accounts' -import { Addresses } from 'viem/tempo' +import { Account as TempoAccount, Addresses } from 'viem/tempo' import { beforeAll, describe, expect, test } from 'vp/test' import { nodeEnv } from '~test/config.js' import { deployEscrow, openChannel } from '~test/tempo/session.js' @@ -140,6 +140,28 @@ describe('createVoucherPayload', () => { ) expect(valid).toBe(true) }) + + test('requires voucherSigner for access-key accounts', async () => { + const accessKey = TempoAccount.fromSecp256k1( + '0x59c6995e998f97a5a0044966f09453863d462d2b3f1446a99f0a3d7b5d0f5a0d', + { access: localAccount }, + ) + const accessKeyClient = createClient({ + account: accessKey, + transport: http('http://127.0.0.1'), + }) + + await expect( + createVoucherPayload( + accessKeyClient, + accessKey, + channelId, + 5_000_000n, + escrowContract, + chainId, + ), + ).rejects.toThrow('Access-key session accounts require `voucherSigner`') + }) }) describe('createClosePayload', () => { @@ -228,11 +250,13 @@ describe.runIf(isLocalnet)('createOpenPayload', () => { chainId: chain.id, }) - expect((result.payload as any).authorizedSigner).toBe(payer.address) + expect(result.payload.action).toBe('open') + if (result.payload.action !== 'open') throw new Error('unexpected action') + expect(result.payload.authorizedSigner).toBe(payer.address) }) - test('uses custom authorizedSigner when provided', async () => { - const customSigner = accounts[5].address + test('derives authorizedSigner from voucherSigner when provided', async () => { + const voucherSigner = accounts[5] const payerClient = createClient({ account: payer, chain, @@ -240,7 +264,7 @@ describe.runIf(isLocalnet)('createOpenPayload', () => { }) const result = await createOpenPayload(payerClient, payer, { - authorizedSigner: customSigner, + voucherSigner, escrowContract: escrow, payee, currency, @@ -249,7 +273,9 @@ describe.runIf(isLocalnet)('createOpenPayload', () => { chainId: chain.id, }) - expect((result.payload as any).authorizedSigner).toBe(customSigner) + expect(result.payload.action).toBe('open') + if (result.payload.action !== 'open') throw new Error('unexpected action') + expect(result.payload.authorizedSigner).toBe(voucherSigner.address) }) }) diff --git a/src/tempo/client/ChannelOps.ts b/src/tempo/client/ChannelOps.ts index 7939e63e..c76d444f 100644 --- a/src/tempo/client/ChannelOps.ts +++ b/src/tempo/client/ChannelOps.ts @@ -18,6 +18,7 @@ import { Abis } from 'viem/tempo' import type { Challenge } from '../../Challenge.js' import * as Credential from '../../Credential.js' +import { getAccountSignerAddress, isAccessKeyAccount } from '../internal/account.js' import * as defaults from '../internal/defaults.js' import { escrowAbi, getOnChainChannel } from '../session/Chain.js' import * as Channel from '../session/Channel.js' @@ -33,6 +34,18 @@ export type ChannelEntry = { opened: boolean } +function resolveVoucherSigner( + account: viem_Account, + voucherSigner?: viem_Account | undefined, +): viem_Account { + if (voucherSigner) return voucherSigner + if (isAccessKeyAccount(account)) + throw new Error( + 'Access-key session accounts require `voucherSigner` for voucher signatures. Pass the direct access-key signer without `access` as `voucherSigner`.', + ) + return account +} + export function resolveChainId(challenge: Challenge): number { const md = challenge.request.methodDetails as { chainId?: number } | undefined return md?.chainId ?? 0 @@ -76,17 +89,16 @@ export async function createVoucherPayload( cumulativeAmount: bigint, escrowContract: Address, chainId: number, - authorizedSigner?: Address | undefined, voucherSigner?: viem_Account | undefined, ): Promise { + const signer = resolveVoucherSigner(account, voucherSigner) const signature = await signVoucher( client, account, { channelId, cumulativeAmount }, escrowContract, chainId, - authorizedSigner, - voucherSigner, + signer, ) return { action: 'voucher', @@ -103,17 +115,16 @@ export async function createClosePayload( cumulativeAmount: bigint, escrowContract: Address, chainId: number, - authorizedSigner?: Address | undefined, voucherSigner?: viem_Account | undefined, ): Promise { + const signer = resolveVoucherSigner(account, voucherSigner) const signature = await signVoucher( client, account, { channelId, cumulativeAmount }, escrowContract, chainId, - authorizedSigner, - voucherSigner, + signer, ) return { action: 'close', @@ -127,7 +138,6 @@ export async function createOpenPayload( client: viem_Client, account: viem_Account, options: { - authorizedSigner?: Address | undefined voucherSigner?: viem_Account | undefined escrowContract: Address payee: Address @@ -139,7 +149,8 @@ export async function createOpenPayload( }, ): Promise<{ entry: ChannelEntry; payload: SessionCredentialPayload }> { const { escrowContract, payee, currency, deposit, initialAmount, chainId, feePayer } = options - const authorizedSigner = options.authorizedSigner ?? account.address + const voucherSigner = resolveVoucherSigner(account, options.voucherSigner) + const authorizedSigner = getAccountSignerAddress(voucherSigner) const salt = Hex.random(32) const channelId = Channel.computeId({ @@ -181,8 +192,7 @@ export async function createOpenPayload( { channelId, cumulativeAmount: initialAmount }, escrowContract, chainId, - options.authorizedSigner, - options.voucherSigner, + voucherSigner, ) return { diff --git a/src/tempo/client/Session.test.ts b/src/tempo/client/Session.test.ts index ee7df4c6..4577a4f5 100644 --- a/src/tempo/client/Session.test.ts +++ b/src/tempo/client/Session.test.ts @@ -210,9 +210,7 @@ describe('session (pure)', () => { if (cred.payload.action !== 'open') throw new Error('unexpected action') expect(cred.payload.authorizedSigner).toBe(voucherSigner.address) - const envelope = SignatureEnvelope.from( - cred.payload.signature as SignatureEnvelope.Serialized, - ) + const envelope = SignatureEnvelope.from(cred.payload.signature) expect(envelope.type).toBe('p256') expect(cred.payload.signature.length).toBe(262) @@ -226,6 +224,80 @@ describe('session (pure)', () => { }, voucherSigner.address, ) + expect(isValid).toBe(false) + expect(cred.source).toBe(`did:pkh:eip155:42431:${pureAccount.address}`) + }) + + test('manual open requires voucherSigner for access-key accounts', async () => { + const accessKey = TempoAccount.fromSecp256k1( + '0x59c6995e998f97a5a0044966f09453863d462d2b3f1446a99f0a3d7b5d0f5a0d', + { access: pureAccount }, + ) + const method = session({ + getClient: () => pureClient, + account: accessKey, + }) + + await expect( + method.createCredential({ + challenge: makeChallenge(), + context: { + action: 'open', + channelId, + cumulativeAmount: '5', + transaction: '0xdeadbeef', + }, + }), + ).rejects.toThrow('Access-key session accounts require `voucherSigner`') + }) + + test('manual open signs access-key vouchers with direct voucher signer', async () => { + const privateKey = '0x59c6995e998f97a5a0044966f09453863d462d2b3f1446a99f0a3d7b5d0f5a0d' + const accessKey = TempoAccount.fromSecp256k1(privateKey, { access: pureAccount }) + const voucherSigner = TempoAccount.fromSecp256k1(privateKey) + const accessKeyClient = createClient({ + account: accessKey, + transport: http('http://127.0.0.1'), + }) + const method = session({ + getClient: () => accessKeyClient, + account: accessKey, + voucherSigner, + }) + + const result = await method.createCredential({ + challenge: makeChallenge(), + context: { + action: 'open', + channelId, + cumulativeAmount: '5', + transaction: '0xdeadbeef', + }, + }) + + const cred = deserializePayload(result) + expect(cred.payload.action).toBe('open') + if (cred.payload.action !== 'open') throw new Error('unexpected action') + expect(cred.payload.authorizedSigner).toBeDefined() + if (!cred.payload.authorizedSigner) throw new Error('missing authorizedSigner') + expect(cred.payload.authorizedSigner.toLowerCase()).toBe( + accessKey.accessKeyAddress.toLowerCase(), + ) + + const envelope = SignatureEnvelope.from(cred.payload.signature) + expect(envelope.type).toBe('secp256k1') + expect(cred.payload.signature.length).toBe(132) + + const isValid = await verifyVoucher( + escrowAddress, + 42431, + { + channelId, + cumulativeAmount: 5_000_000n, + signature: cred.payload.signature, + }, + accessKey.accessKeyAddress, + ) expect(isValid).toBe(true) expect(cred.source).toBe(`did:pkh:eip155:42431:${pureAccount.address}`) }) diff --git a/src/tempo/client/Session.ts b/src/tempo/client/Session.ts index ebdd2222..807c17e4 100644 --- a/src/tempo/client/Session.ts +++ b/src/tempo/client/Session.ts @@ -7,6 +7,7 @@ import * as Method from '../../Method.js' import * as Account from '../../viem/Account.js' import * as Client from '../../viem/Client.js' import * as z from '../../zod.js' +import { getAccountSignerAddress, isAccessKeyAccount } from '../internal/account.js' import * as defaults from '../internal/defaults.js' import * as Methods from '../Methods.js' import type { SessionCredentialPayload } from '../session/Types.js' @@ -27,8 +28,6 @@ export const sessionContextSchema = z.object({ cumulativeAmount: z.optional(z.amount()), cumulativeAmountRaw: z.optional(z.string()), transaction: z.optional(z.string()), - authorizedSigner: z.optional(z.string()), - voucherSigner: z.optional(z.custom()), additionalDeposit: z.optional(z.amount()), additionalDepositRaw: z.optional(z.string()), depositRaw: z.optional(z.string()), @@ -36,6 +35,18 @@ export const sessionContextSchema = z.object({ export type SessionContext = z.infer +function resolveVoucherSigner( + account: viem_Account, + voucherSigner?: viem_Account | undefined, +): viem_Account { + if (voucherSigner) return voucherSigner + if (isAccessKeyAccount(account)) + throw new Error( + 'Access-key session accounts require `voucherSigner` for voucher signatures. Pass the direct access-key signer without `access` as `voucherSigner`.', + ) + return account +} + /** * Creates a session payment method for use with `Mppx.create()`. * @@ -80,22 +91,9 @@ export function session(parameters: session.Parameters = {}) { rpcUrl: defaults.rpcUrl, }) const getAccount = Account.getResolver({ account: parameters.account }) - function getVoucherSigning(account: viem_Account, context?: SessionContext | undefined) { - const contextVoucherSigner = context?.voucherSigner - const voucherSigner = contextVoucherSigner ?? parameters.voucherSigner - const authorizedSigner = - (context?.authorizedSigner as Address | undefined) ?? - contextVoucherSigner?.address ?? - parameters.authorizedSigner ?? - parameters.voucherSigner?.address ?? - (account as unknown as { accessKeyAddress?: Address }).accessKeyAddress - - if ( - authorizedSigner && - voucherSigner && - authorizedSigner.toLowerCase() !== voucherSigner.address.toLowerCase() - ) - throw new Error('authorizedSigner must match voucherSigner.address') + function getVoucherSigning(account: viem_Account) { + const voucherSigner = resolveVoucherSigner(account, parameters.voucherSigner) + const authorizedSigner = getAccountSignerAddress(voucherSigner) return { authorizedSigner, voucherSigner } } @@ -158,7 +156,7 @@ export function session(parameters: session.Parameters = {}) { ) })() - const voucherSigning = getVoucherSigning(account, context) + const voucherSigning = getVoucherSigning(account) const key = channelKey(payee, currency, escrowContract) let entry = channels.get(key) @@ -203,13 +201,11 @@ export function session(parameters: session.Parameters = {}) { entry.cumulativeAmount, escrowContract, chainId, - voucherSigning.authorizedSigner, voucherSigning.voucherSigner, ) notifyUpdate(entry) } else { const result = await createOpenPayload(client, account, { - authorizedSigner: voucherSigning.authorizedSigner, voucherSigner: voucherSigning.voucherSigner, escrowContract, payee, @@ -242,7 +238,7 @@ export function session(parameters: session.Parameters = {}) { const action = context.action! const { channelId: channelIdRaw, transaction } = context - const voucherSigning = getVoucherSigning(account, context) + const voucherSigning = getVoucherSigning(account) const channelId = channelIdRaw as Hex.Hex const cumulativeAmount = context.cumulativeAmountRaw ? BigInt(context.cumulativeAmountRaw) @@ -271,7 +267,6 @@ export function session(parameters: session.Parameters = {}) { { channelId, cumulativeAmount }, escrowContract, chainId, - voucherSigning.authorizedSigner, voucherSigning.voucherSigner, ) payload = { @@ -279,7 +274,7 @@ export function session(parameters: session.Parameters = {}) { type: 'transaction', channelId, transaction: transaction as Hex.Hex, - authorizedSigner: voucherSigning.authorizedSigner ?? account.address, + authorizedSigner: voucherSigning.authorizedSigner, cumulativeAmount: cumulativeAmount.toString(), signature, } @@ -309,7 +304,6 @@ export function session(parameters: session.Parameters = {}) { cumulativeAmount, escrowContract, chainId, - voucherSigning.authorizedSigner, voucherSigning.voucherSigner, ) const key = channelIdToKey.get(channelId) @@ -333,7 +327,6 @@ export function session(parameters: session.Parameters = {}) { { channelId, cumulativeAmount }, escrowContract, chainId, - voucherSigning.authorizedSigner, voucherSigning.voucherSigner, ) payload = { @@ -382,9 +375,7 @@ export function session(parameters: session.Parameters = {}) { export declare namespace session { type Parameters = Account.getResolver.Parameters & Client.getResolver.Parameters & { - /** Address authorized to sign vouchers. Defaults to the voucher signer address, access key address, or account address. */ - authorizedSigner?: Address | undefined - /** Account that signs voucher digests directly. Use for delegated P256/WebCrypto voucher signing while the root account funds the channel. */ + /** Account that signs voucher digests directly. Non-secp256k1 vouchers are not accepted by escrow verification until TIP-1020 settlement support ships. */ voucherSigner?: viem_Account | undefined /** Token decimals for parsing human-readable amounts (default: 6). */ decimals?: number | undefined diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index 25c04c89..f4e80897 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -124,7 +124,6 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa const method = sessionPlugin({ account: parameters.account, - authorizedSigner: parameters.authorizedSigner, voucherSigner: parameters.voucherSigner, getClient: parameters.client ? () => parameters.client! : parameters.getClient, escrowContract: parameters.escrowContract, @@ -854,9 +853,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa export declare namespace sessionManager { type Parameters = Account.getResolver.Parameters & Client.getResolver.Parameters & { - /** Address authorized to sign vouchers. Defaults to the account address. */ - authorizedSigner?: Address | undefined - /** Account that signs voucher digests directly. Use for delegated P256/WebCrypto voucher signing. */ + /** Account that signs voucher digests directly. Non-secp256k1 vouchers are not accepted by escrow verification until TIP-1020 settlement support ships. */ voucherSigner?: viem_Account | undefined /** Viem client instance. Shorthand for `getClient: () => client`. */ client?: import('viem').Client | undefined diff --git a/src/tempo/internal/account.ts b/src/tempo/internal/account.ts index a417ee5d..387c0224 100644 --- a/src/tempo/internal/account.ts +++ b/src/tempo/internal/account.ts @@ -1,4 +1,17 @@ import type { Account, Address } from 'viem' +import type { Account as TempoAccount } from 'viem/tempo' + +/** Returns whether an account is a Tempo access-key account. */ +export function isAccessKeyAccount( + account: Account, +): account is Account & TempoAccount.AccessKeyAccount { + return 'accessKeyAddress' in account +} + +/** Returns the address that should be authorized for direct account signatures. */ +export function getAccountSignerAddress(account: Account): Address { + return isAccessKeyAccount(account) ? account.accessKeyAddress : account.address +} /** * Resolves a recipient address and optional fee payer from flexible input parameters. diff --git a/src/tempo/session/Voucher.test.ts b/src/tempo/session/Voucher.test.ts index d6d2c777..583876c0 100644 --- a/src/tempo/session/Voucher.test.ts +++ b/src/tempo/session/Voucher.test.ts @@ -1,3 +1,4 @@ +import { P256 } from 'ox' import { SignatureEnvelope } from 'ox/tempo' import { createClient, http } from 'viem' import { privateKeyToAccount } from 'viem/accounts' @@ -41,7 +42,7 @@ describe('Voucher', () => { expect(isValid).toBe(true) }) - test('signVoucher and verifyVoucher round-trip with WebCrypto P256', async () => { + test('signVoucher creates direct WebCrypto P256 voucher signatures', async () => { const keyPair = await WebCryptoP256.createKeyPair() const p256Account = TempoAccount.fromWebCryptoP256(keyPair) const p256Client = createClient({ @@ -56,7 +57,7 @@ describe('Voucher', () => { escrowContract, chainId, ) - const envelope = SignatureEnvelope.from(signature as SignatureEnvelope.Serialized) + const envelope = SignatureEnvelope.from(signature) expect(envelope.type).toBe('p256') if (envelope.type !== 'p256') throw new Error('unexpected signature type') @@ -69,9 +70,93 @@ describe('Voucher', () => { { channelId, cumulativeAmount, signature }, p256Account.address, ) + expect(isValid).toBe(false) + }) + + test('signVoucher creates direct WebAuthn voucher signatures', async () => { + const webAuthnAccount = TempoAccount.fromHeadlessWebAuthn(P256.randomPrivateKey(), { + origin: 'https://example.com', + rpId: 'example.com', + }) + const webAuthnClient = createClient({ + account: webAuthnAccount, + transport: http('http://127.0.0.1'), + }) + + const signature = await signVoucher( + webAuthnClient, + webAuthnAccount, + { channelId, cumulativeAmount }, + escrowContract, + chainId, + ) + const envelope = SignatureEnvelope.from(signature) + + expect(envelope.type).toBe('webAuthn') + + const isValid = await verifyVoucher( + escrowContract, + chainId, + { channelId, cumulativeAmount, signature }, + webAuthnAccount.address, + ) + expect(isValid).toBe(false) + }) + + test('signVoucher unwraps v1 secp256k1 access-key envelopes', async () => { + const accessKey = TempoAccount.fromSecp256k1( + '0x59c6995e998f97a5a0044966f09453863d462d2b3f1446a99f0a3d7b5d0f5a0d', + { access: account, internal_version: 'v1' }, + ) + const accessKeyClient = createClient({ + account: accessKey, + transport: http('http://127.0.0.1'), + }) + + const signature = await signVoucher( + accessKeyClient, + accessKey, + { channelId, cumulativeAmount }, + escrowContract, + chainId, + ) + const envelope = SignatureEnvelope.from(signature) + + expect(envelope.type).toBe('secp256k1') + expect(signature.length).toBe(132) + + const isValid = await verifyVoucher( + escrowContract, + chainId, + { channelId, cumulativeAmount, signature }, + accessKey.accessKeyAddress, + ) expect(isValid).toBe(true) }) + test('signVoucher rejects v2 secp256k1 keychain signatures after unwrapping', async () => { + const accessKey = TempoAccount.fromSecp256k1( + '0x59c6995e998f97a5a0044966f09453863d462d2b3f1446a99f0a3d7b5d0f5a0d', + { access: account }, + ) + const accessKeyClient = createClient({ + account: accessKey, + transport: http('http://127.0.0.1'), + }) + + await expect( + signVoucher( + accessKeyClient, + accessKey, + { channelId, cumulativeAmount }, + escrowContract, + chainId, + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: voucher signature does not match voucher signer]`, + ) + }) + test('verifyVoucher rejects keychain envelopes', async () => { const keyPair = await WebCryptoP256.createKeyPair() const p256Account = TempoAccount.fromWebCryptoP256(keyPair) @@ -90,7 +175,7 @@ describe('Voucher', () => { type: 'keychain', version: 'v2', userAddress: account.address, - inner: SignatureEnvelope.from(signature as SignatureEnvelope.Serialized), + inner: SignatureEnvelope.from(signature), }) const isValid = await verifyVoucher( @@ -102,11 +187,11 @@ describe('Voucher', () => { expect(isValid).toBe(false) }) - test('signVoucher rejects keychain signer accounts', async () => { - const accessKey = TempoAccount.fromSecp256k1( - '0x59c6995e998f97a5a0044966f09453863d462d2b3f1446a99f0a3d7b5d0f5a0d', - { access: account }, - ) + test('signVoucher rejects non-secp256k1 keychain signer accounts', async () => { + const accessKey = TempoAccount.fromP256(P256.randomPrivateKey(), { + access: account, + internal_version: 'v1', + }) const accessKeyClient = createClient({ account: accessKey, transport: http('http://127.0.0.1'), @@ -119,13 +204,54 @@ describe('Voucher', () => { { channelId, cumulativeAmount }, escrowContract, chainId, - accessKey.accessKeyAddress, ), ).rejects.toThrowErrorMatchingInlineSnapshot( - `[Error: Session vouchers must be signed directly by authorizedSigner; pass a direct voucherSigner instead of a keychain account.]`, + `[Error: Session vouchers only unwrap secp256k1 keychain signatures; pass a direct voucherSigner for other key types.]`, ) }) + test('verifyVoucher rejects magic-suffixed secp256k1 signatures', async () => { + const signature = await signVoucher( + client, + account, + { channelId, cumulativeAmount }, + escrowContract, + chainId, + ) + const signatureWithMagic = SignatureEnvelope.serialize(SignatureEnvelope.from(signature), { + magic: true, + }) + + const isValid = await verifyVoucher( + escrowContract, + chainId, + { channelId, cumulativeAmount, signature: signatureWithMagic }, + account.address, + ) + expect(isValid).toBe(false) + }) + + test('verifyVoucher rejects EIP-155-style secp256k1 v values', async () => { + const signature = await signVoucher( + client, + account, + { channelId, cumulativeAmount }, + escrowContract, + chainId, + ) + const signatureWithEip155V = `${signature.slice(0, -2)}${ + signature.endsWith('1b') ? '23' : '24' + }` as `0x${string}` + + const isValid = await verifyVoucher( + escrowContract, + chainId, + { channelId, cumulativeAmount, signature: signatureWithEip155V }, + account.address, + ) + expect(isValid).toBe(false) + }) + test('verifyVoucher rejects wrong signer', async () => { const signature = await signVoucher( client, diff --git a/src/tempo/session/Voucher.ts b/src/tempo/session/Voucher.ts index 9b8a0a4f..93d136ef 100644 --- a/src/tempo/session/Voucher.ts +++ b/src/tempo/session/Voucher.ts @@ -1,9 +1,10 @@ -import type { Address } from 'ox' +import { Signature, type Address } from 'ox' import { SignatureEnvelope } from 'ox/tempo' import type { Account, Client, Hex } from 'viem' import { hashTypedData } from 'viem' import { signTypedData } from 'viem/actions' +import { getAccountSignerAddress } from '../internal/account.js' import type { SignedVoucher, Voucher } from './Types.js' /** Must match the on-chain TempoStreamChannel DOMAIN_SEPARATOR name. */ @@ -34,6 +35,8 @@ const voucherTypes = { ], } as const +const acceptTip1020VoucherSignatures = false + function getVoucherMessage(message: Voucher) { return { channelId: message.channelId, @@ -56,10 +59,10 @@ async function signVoucherDigest( message: Voucher, escrowContract: Address.Address, chainId: number, + digest: Hex, ): Promise { - const sign = (account as { sign?: ((parameters: { hash: Hex }) => Promise) | undefined }) - .sign - if (sign) return sign({ hash: getVoucherDigest(escrowContract, chainId, message) }) + const sign = 'sign' in account ? account.sign : undefined + if (sign) return sign({ hash: digest }) return signTypedData(client, { account, @@ -71,23 +74,26 @@ async function signVoucherDigest( } function normalizeVoucherSignature(signature: Hex): Hex { - try { - const envelope = SignatureEnvelope.from(signature as SignatureEnvelope.Serialized) + const envelope = SignatureEnvelope.from(signature) - if (envelope.type === 'keychain') + if (envelope.type === 'keychain') { + if (envelope.inner.type !== 'secp256k1') throw new Error( - 'Session vouchers must be signed directly by authorizedSigner; pass a direct voucherSigner instead of a keychain account.', + 'Session vouchers only unwrap secp256k1 keychain signatures; pass a direct voucherSigner for other key types.', ) - // Tempo local accounts may append signature-envelope magic bytes for RPC - // routing. Voucher signatures are direct TIP-1020 envelopes. - return SignatureEnvelope.serialize(envelope) - } catch (error) { - if (error instanceof Error && error.message.startsWith('Session vouchers must be signed')) - throw error + return Signature.toHex(envelope.inner.signature) } - return signature + // Tempo local accounts may append signature-envelope magic bytes for RPC + // routing. Voucher signatures are direct envelopes without magic bytes. + return SignatureEnvelope.serialize(envelope) +} + +function acceptsVoucherEnvelope(envelope: SignatureEnvelope.SignatureEnvelope): boolean { + if (envelope.type === 'keychain') return false + if (envelope.type === 'secp256k1') return true + return acceptTip1020VoucherSignatures } /** @@ -99,27 +105,39 @@ export async function signVoucher( message: Voucher, escrowContract: Address.Address, chainId: number, - authorizedSigner?: Address.Address | undefined, voucherSigner?: Account | undefined, ): Promise { const signer = voucherSigner ?? account - if ((signer as { accessKeyAddress?: Address.Address }).accessKeyAddress) - throw new Error( - 'Session vouchers must be signed directly by authorizedSigner; pass a direct voucherSigner instead of a keychain account.', - ) - if (authorizedSigner && signer.address.toLowerCase() !== authorizedSigner.toLowerCase()) - throw new Error('authorizedSigner must match voucher signer address') + const expectedSigner = getAccountSignerAddress(signer) + + const digest = getVoucherDigest(escrowContract, chainId, message) + const signature = await signVoucherDigest( + client, + signer, + message, + escrowContract, + chainId, + digest, + ) + const normalized = normalizeVoucherSignature(signature) + const envelope = SignatureEnvelope.from(normalized) - const signature = await signVoucherDigest(client, signer, message, escrowContract, chainId) + if ( + !SignatureEnvelope.verify(envelope, { + address: expectedSigner, + payload: digest, + }) + ) + throw new Error('voucher signature does not match voucher signer') - return normalizeVoucherSignature(signature) + return normalized } /** * Verify a voucher signature matches the expected signer. * - * Accepts direct TIP-1020 signatures. Keychain envelopes are rejected because - * direct voucher verification checks `authorizedSigner`, not wrapper metadata. + * Accepts canonical raw secp256k1 voucher signatures today. Direct TIP-1020 + * voucher signatures are wired but disabled until escrow verification ships. */ export async function verifyVoucher( escrowContract: Address.Address, @@ -130,7 +148,10 @@ export async function verifyVoucher( try { const envelope = SignatureEnvelope.from(voucher.signature) - if (envelope.type === 'keychain') return false + if (!acceptsVoucherEnvelope(envelope)) return false + + const canonical = SignatureEnvelope.serialize(envelope) + if (canonical.toLowerCase() !== voucher.signature.toLowerCase()) return false return SignatureEnvelope.verify(envelope, { address: expectedSigner,