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 380680a9..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,15 +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, + signer, ) return { action: 'voucher', @@ -101,15 +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, + signer, ) return { action: 'close', @@ -123,7 +138,7 @@ export async function createOpenPayload( client: viem_Client, account: viem_Account, options: { - authorizedSigner?: Address | undefined + voucherSigner?: viem_Account | undefined escrowContract: Address payee: Address currency: Address @@ -134,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({ @@ -176,7 +192,7 @@ export async function createOpenPayload( { channelId, cumulativeAmount: initialAmount }, escrowContract, chainId, - options.authorizedSigner, + voucherSigner, ) return { diff --git a/src/tempo/client/Session.test.ts b/src/tempo/client/Session.test.ts index f8475f87..4577a4f5 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,122 @@ 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) + 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(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}`) + }) + 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..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,7 +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()), additionalDeposit: z.optional(z.amount()), additionalDepositRaw: z.optional(z.string()), depositRaw: z.optional(z.string()), @@ -35,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()`. * @@ -79,9 +91,12 @@ 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) { + const voucherSigner = resolveVoucherSigner(account, parameters.voucherSigner) + const authorizedSigner = getAccountSignerAddress(voucherSigner) + + return { authorizedSigner, voucherSigner } + } const maxDeposit = parameters.maxDeposit !== undefined ? parseUnits(parameters.maxDeposit, decimals) : undefined @@ -141,7 +156,7 @@ export function session(parameters: session.Parameters = {}) { ) })() - const authorizedSigner = getAuthorizedSigner(account) + const voucherSigning = getVoucherSigning(account) const key = channelKey(payee, currency, escrowContract) let entry = channels.get(key) @@ -186,12 +201,12 @@ export function session(parameters: session.Parameters = {}) { entry.cumulativeAmount, escrowContract, chainId, - authorizedSigner, + voucherSigning.voucherSigner, ) notifyUpdate(entry) } else { const result = await createOpenPayload(client, account, { - authorizedSigner, + voucherSigner: voucherSigning.voucherSigner, escrowContract, payee, currency, @@ -222,12 +237,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) const channelId = channelIdRaw as Hex.Hex const cumulativeAmount = context.cumulativeAmountRaw ? BigInt(context.cumulativeAmountRaw) @@ -256,14 +267,14 @@ export function session(parameters: session.Parameters = {}) { { channelId, cumulativeAmount }, escrowContract, chainId, - authorizedSigner, + voucherSigning.voucherSigner, ) payload = { action: 'open', type: 'transaction', channelId, transaction: transaction as Hex.Hex, - authorizedSigner: authorizedSigner ?? account.address, + authorizedSigner: voucherSigning.authorizedSigner, cumulativeAmount: cumulativeAmount.toString(), signature, } @@ -293,7 +304,7 @@ export function session(parameters: session.Parameters = {}) { cumulativeAmount, escrowContract, chainId, - authorizedSigner, + voucherSigning.voucherSigner, ) const key = channelIdToKey.get(channelId) if (key) { @@ -316,7 +327,7 @@ export function session(parameters: session.Parameters = {}) { { channelId, cumulativeAmount }, escrowContract, chainId, - authorizedSigner, + voucherSigning.voucherSigner, ) payload = { action: 'close', @@ -364,8 +375,8 @@ 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. */ - authorizedSigner?: Address | undefined + /** 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 /** 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..f4e80897 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' @@ -124,7 +124,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, @@ -853,8 +853,8 @@ 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. 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 /** Token decimals used to convert `maxDeposit` to raw units. Defaults to `6`. */ 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 0e82ae87..583876c0 100644 --- a/src/tempo/session/Voucher.test.ts +++ b/src/tempo/session/Voucher.test.ts @@ -1,5 +1,8 @@ +import { P256 } from 'ox' +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 +42,216 @@ describe('Voucher', () => { expect(isValid).toBe(true) }) + test('signVoucher creates direct WebCrypto P256 voucher signatures', 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) + + 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(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) + 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), + }) + + const isValid = await verifyVoucher( + escrowContract, + chainId, + { channelId, cumulativeAmount, signature: keychainSignature }, + p256Account.address, + ) + expect(isValid).toBe(false) + }) + + 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'), + }) + + await expect( + signVoucher( + accessKeyClient, + accessKey, + { channelId, cumulativeAmount }, + escrowContract, + chainId, + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[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 8b9f51ed..93d136ef 100644 --- a/src/tempo/session/Voucher.ts +++ b/src/tempo/session/Voucher.ts @@ -1,10 +1,10 @@ -import { type Address, Signature } from 'ox' +import { Signature, 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 { getAccountSignerAddress } from '../internal/account.js' import type { SignedVoucher, Voucher } from './Types.js' /** Must match the on-chain TempoStreamChannel DOMAIN_SEPARATOR name. */ @@ -35,48 +35,109 @@ const voucherTypes = { ], } as const -/** - * Sign a voucher with an account. - */ -export async function signVoucher( +const acceptTip1020VoucherSignatures = false + +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, + digest: Hex, ): Promise { - const signature = await signTypedData(client, { + const sign = 'sign' in account ? account.sign : undefined + if (sign) return sign({ hash: digest }) + + 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 { + const envelope = SignatureEnvelope.from(signature) + + if (envelope.type === 'keychain') { + if (envelope.inner.type !== 'secp256k1') + throw new Error( + 'Session vouchers only unwrap secp256k1 keychain signatures; pass a direct voucherSigner for other key types.', + ) - // 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 {} + 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 +} + +/** + * Sign a voucher with an account. + */ +export async function signVoucher( + client: Client, + account: Account, + message: Voucher, + escrowContract: Address.Address, + chainId: number, + voucherSigner?: Account | undefined, +): Promise { + const signer = voucherSigner ?? account + 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) + + if ( + !SignatureEnvelope.verify(envelope, { + address: expectedSigner, + payload: digest, + }) + ) + throw new Error('voucher signature does not match voucher signer') + + return normalized } /** * 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 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, @@ -85,31 +146,17 @@ 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, + if (!acceptsVoucherEnvelope(envelope)) return false + + const canonical = SignatureEnvelope.serialize(envelope) + if (canonical.toLowerCase() !== voucher.signature.toLowerCase()) return false + + return SignatureEnvelope.verify(envelope, { + address: expectedSigner, + payload: getVoucherDigest(escrowContract, chainId, voucher), }) - return TempoAddress.isEqual(signer, expectedSigner) } catch { return false }