diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index 1d3cc2ec..e1426e12 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -1995,6 +1995,268 @@ describe('tempo', () => { httpServer.close() }) + test('behavior: proof credential remains reusable until expiry without store', 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) + expect(response1.status).toBe(402) + + 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:${chain.id}:${accounts[1].address}`, + }) + + const response2 = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(response2.status).toBe(200) + + const replayResponse = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(replayResponse.status).toBe(200) + + httpServer.close() + }) + + test('behavior: rejects replayed proof credential when store is configured', async () => { + const replayStore = Store.memory() + const server_ = Mppx_server.create({ + methods: [ + tempo_server.charge({ + getClient() { + return client + }, + currency: asset, + account: accounts[0], + store: replayStore, + }), + ], + realm, + secretKey, + }) + + 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) + expect(response1.status).toBe(402) + + 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:${chain.id}:${accounts[1].address}`, + }) + + const response2 = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(response2.status).toBe(200) + + const replayResponse = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(replayResponse.status).toBe(402) + const replayBody = (await replayResponse.json()) as { detail: string } + expect(replayBody.detail).toContain('Proof credential has already been used.') + + httpServer.close() + }) + + test('behavior: shared store rejects proof replay across server instances', async () => { + const replayStore = Store.memory() + const serverA = Mppx_server.create({ + methods: [ + tempo_server.charge({ + getClient() { + return client + }, + currency: asset, + account: accounts[0], + store: replayStore, + }), + ], + realm, + secretKey, + }) + const serverB = Mppx_server.create({ + methods: [ + tempo_server.charge({ + getClient() { + return client + }, + currency: asset, + account: accounts[0], + store: replayStore, + }), + ], + realm, + secretKey, + }) + + const httpServer = await Http.createServer(async (req, res) => { + const route = new URL(req.url!, 'https://example.com').pathname + const handler = route === '/a' ? serverA : serverB + const result = await Mppx_server.toNodeListener( + handler.charge({ amount: '0', decimals: 6 }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response1 = await fetch(`${httpServer.url}/a`) + expect(response1.status).toBe(402) + + 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:${chain.id}:${accounts[1].address}`, + }) + + const response2 = await fetch(`${httpServer.url}/a`, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(response2.status).toBe(200) + + const replayResponse = await fetch(`${httpServer.url}/b`, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(replayResponse.status).toBe(402) + const replayBody = (await replayResponse.json()) as { detail: string } + expect(replayBody.detail).toContain('Proof credential has already been used.') + + httpServer.close() + }) + + test('behavior: store keys proof replay protection by challenge ID', async () => { + const replayStore = Store.memory() + const server_ = Mppx_server.create({ + methods: [ + tempo_server.charge({ + getClient() { + return client + }, + currency: asset, + account: accounts[0], + store: replayStore, + }), + ], + realm, + secretKey, + }) + + 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) + expect(response1.status).toBe(402) + + const challenge1 = Challenge.fromResponse(response1, { + methods: [tempo_client.charge()], + }) + + const signature1 = await signTypedData(client, { + account: accounts[1], + domain: Proof.domain(chain.id), + types: Proof.types, + primaryType: 'Proof', + message: Proof.message(challenge1.id), + }) + + const credential1 = Credential.from({ + challenge: challenge1, + payload: { signature: signature1, type: 'proof' as const }, + source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`, + }) + + const response2 = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential1) }, + }) + expect(response2.status).toBe(200) + + const response3 = await fetch(httpServer.url) + expect(response3.status).toBe(402) + + const challenge2 = Challenge.fromResponse(response3, { + methods: [tempo_client.charge()], + }) + expect(challenge2.id).not.toBe(challenge1.id) + + const signature2 = await signTypedData(client, { + account: accounts[1], + domain: Proof.domain(chain.id), + types: Proof.types, + primaryType: 'Proof', + message: Proof.message(challenge2.id), + }) + + const credential2 = Credential.from({ + challenge: challenge2, + payload: { signature: signature2, type: 'proof' as const }, + source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`, + }) + + const response4 = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential2) }, + }) + expect(response4.status).toBe(200) + + httpServer.close() + }) + test('behavior: rejects proof with wrong signer', 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..f40acc35 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -51,6 +51,7 @@ export function charge( waitForConfirmation = true, } = parameters const store = (parameters.store ?? Store.memory()) as Store.Store + const proofStore = parameters.store as Store.Store | undefined const { recipient, feePayer, feePayerUrl } = Account.resolve(parameters) @@ -172,6 +173,11 @@ export function charge( }) if (!valid) throw new MismatchError('Proof signature does not match source.', {}) + if (proofStore) { + await assertProofUnused(proofStore, challenge.id) + await markProofUsed(proofStore, challenge.id) + } + return { method: 'tempo', status: 'success', @@ -285,10 +291,15 @@ export declare namespace charge { /** Testnet mode. */ testnet?: boolean | undefined /** - * Store for transaction hash replay protection. + * Store for charge replay protection. * - * Use a shared store in multi-instance deployments so consumed hashes are - * visible across all server instances. + * Non-zero charge flows default to an in-memory store if omitted. For + * zero-dollar proof auth, replay prevention is enabled only when a store + * is explicitly provided; otherwise proofs remain reusable until the + * challenge expires. + * + * Use a shared store in multi-instance deployments so consumed hashes and + * proofs are visible across all server instances. */ store?: Store.Store | undefined /** @@ -504,6 +515,11 @@ function getHashStoreKey(hash: `0x${string}`): `mppx:charge:${string}` { return `mppx:charge:${hash.toLowerCase()}` } +/** @internal */ +function getProofStoreKey(challengeId: string): `mppx:charge:${string}` { + return `mppx:charge:proof:${challengeId}` +} + /** @internal */ async function assertHashUnused( store: Store.Store, @@ -521,6 +537,23 @@ async function markHashUsed( await store.put(getHashStoreKey(hash), Date.now()) } +/** @internal */ +async function assertProofUnused( + store: Store.Store, + challengeId: string, +): Promise { + const seen = await store.get(getProofStoreKey(challengeId)) + if (seen !== null) throw new Error('Proof credential has already been used.') +} + +/** @internal */ +async function markProofUsed( + store: Store.Store, + challengeId: string, +): Promise { + await store.put(getProofStoreKey(challengeId), Date.now()) +} + /** @internal */ function toReceipt(receipt: TransactionReceipt) { const { status, transactionHash } = receipt