Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ten-cameras-grab.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 47 additions & 0 deletions src/tempo/internal/proof.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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)
})
}
})
16 changes: 16 additions & 0 deletions src/tempo/internal/proof.ts
Original file line number Diff line number Diff line change
@@ -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' }],
Expand All @@ -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 }
}
36 changes: 36 additions & 0 deletions src/tempo/server/Charge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 6 additions & 2 deletions src/tempo/server/Charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,15 @@ export function charge<const parameters extends charge.Parameters>(
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',
Expand Down
Loading