diff --git a/.changeset/ten-cameras-grab.md b/.changeset/ten-cameras-grab.md new file mode 100644 index 00000000..ac21640f --- /dev/null +++ b/.changeset/ten-cameras-grab.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Validate the `did:pkh:eip155` source DID on zero-dollar Tempo proof credentials. Servers now reject malformed proof source DIDs and chain ID mismatches between the source DID and the challenge signing domain. diff --git a/src/tempo/internal/proof.test.ts b/src/tempo/internal/proof.test.ts index 93b3e0cb..31037717 100644 --- a/src/tempo/internal/proof.test.ts +++ b/src/tempo/internal/proof.test.ts @@ -2,6 +2,47 @@ import { describe, expect, test } from 'vp/test' import * as Proof from './proof.js' +const parseProofSourceCases = [ + { + expected: { + address: '0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141', + chainId: 42431, + }, + name: 'parses a valid did:pkh:eip155 source', + source: 'did:pkh:eip155:42431:0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141', + }, + { + expected: null, + name: 'rejects non-numeric chain ids', + source: 'did:pkh:eip155:not-a-number:0x1234', + }, + { + expected: null, + name: 'rejects leading-zero chain ids', + source: 'did:pkh:eip155:01:0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141', + }, + { + expected: null, + name: 'rejects unsafe integer chain ids', + source: 'did:pkh:eip155:9007199254740992:0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141', + }, + { + expected: null, + name: 'rejects invalid addresses', + source: 'did:pkh:eip155:42431:not-an-address', + }, + { + expected: null, + name: 'rejects extra path segments', + source: 'did:pkh:eip155:42431:0xAbCdEf1234567890AbCdEf1234567890AbCdEf12:extra', + }, + { + expected: null, + name: 'rejects unsupported namespaces', + source: 'did:pkh:solana:42431:0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141', + }, +] as const + describe('Proof', () => { test('types has Proof with challengeId field', () => { expect(Proof.types).toEqual({ @@ -33,4 +74,10 @@ describe('Proof', () => { const address = '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12' expect(Proof.proofSource({ address, chainId: 1 })).toBe(`did:pkh:eip155:1:${address}`) }) + + for (const { expected, name, source } of parseProofSourceCases) { + test(`parseProofSource ${name}`, () => { + expect(Proof.parseProofSource(source)).toEqual(expected) + }) + } }) diff --git a/src/tempo/internal/proof.ts b/src/tempo/internal/proof.ts index 1164f665..3474ad8f 100644 --- a/src/tempo/internal/proof.ts +++ b/src/tempo/internal/proof.ts @@ -1,3 +1,5 @@ +import { isAddress, type Address } from 'viem' + /** EIP-712 typed data types for proof credentials. */ export const types = { Proof: [{ name: 'challengeId', type: 'string' }], @@ -17,3 +19,17 @@ export function message(challengeId: string) { export function proofSource(parameters: { address: string; chainId: number }): string { return `did:pkh:eip155:${parameters.chainId}:${parameters.address}` } + +/** Parses a proof credential `did:pkh:eip155` source DID. */ +export function parseProofSource(source: string): { address: Address; chainId: number } | null { + const match = /^did:pkh:eip155:(0|[1-9]\d*):([^:]+)$/.exec(source) + if (!match) return null + + const chainIdText = match[1]! + const address = match[2]! + const chainId = Number(chainIdText) + if (!Number.isSafeInteger(chainId)) return null + if (!isAddress(address)) return null + + return { address: address as Address, chainId } +} diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index 1d3cc2ec..62b2fe29 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -2244,6 +2244,42 @@ describe('tempo', () => { httpServer.close() }) + test('behavior: rejects proof with mismatched source DID chainId', async () => { + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + server.charge({ amount: '0', decimals: 6 }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response1 = await fetch(httpServer.url) + const challenge = Challenge.fromResponse(response1, { + methods: [tempo_client.charge()], + }) + + const signature = await signTypedData(client, { + account: accounts[1], + domain: Proof.domain(chain.id), + types: Proof.types, + primaryType: 'Proof', + message: Proof.message(challenge.id), + }) + + const credential = Credential.from({ + challenge, + payload: { signature, type: 'proof' as const }, + source: `did:pkh:eip155:1:${accounts[1].address}`, + }) + + const response2 = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(response2.status).toBe(402) + + httpServer.close() + }) + test('behavior: rejects proof with malformed source DID', async () => { const httpServer = await Http.createServer(async (req, res) => { const result = await Mppx_server.toNodeListener( diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index ecf44f37..0d2378e4 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -159,11 +159,15 @@ export function charge( if (!expectedSource) throw new MismatchError('Proof credential must include a source.', {}) - const sourceAddress = expectedSource.split(':').pop() as `0x${string}` const resolvedChainId = challenge.request.methodDetails?.chainId ?? chainId! + const source = Proof.parseProofSource(expectedSource) + + if (!source || source.chainId !== resolvedChainId) { + throw new MismatchError('Proof credential source is invalid.', {}) + } const valid = await verifyTypedData(client, { - address: sourceAddress, + address: source.address, domain: Proof.domain(resolvedChainId), types: Proof.types, primaryType: 'Proof',