From 9e2ec1a8796847483958fabb61e797e1d48a0485 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 30 Mar 2026 09:25:38 -0700 Subject: [PATCH 1/5] fix: fail-closed expiry enforcement in credential verification Previously, credentials with no expires field were silently accepted because the check used `if (expires && ...)` which skips when expires is undefined. Now the handler rejects credentials missing expires with an InvalidChallengeError before checking the timestamp. Method-specific verify functions (Stripe, Tempo) also enforce fail-closed. Adds test for missing-expires rejection. MPP-F4 --- src/server/Mppx.test.ts | 44 +++++++++++++++++++++++++++++++++++++ src/server/Mppx.ts | 15 +++++++++++-- src/stripe/server/Charge.ts | 4 +++- src/tempo/server/Charge.ts | 3 ++- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index 68e0feaa..3e6f889d 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -422,6 +422,50 @@ describe('request handler', () => { `) expect((body as { detail: string }).detail).toContain('Payment expired at') }) + test('returns 402 when credential challenge has no expires (fail-closed)', async () => { + const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({ + amount: '1000', + currency: asset, + expires: new Date(Date.now() + 60_000).toISOString(), + recipient: accounts[0].address, + }) + + // Get a valid challenge from the server to capture the exact request shape + const firstResult = await handle(new Request('https://example.com/resource')) + expect(firstResult.status).toBe(402) + if (firstResult.status !== 402) throw new Error() + + const serverChallenge = Challenge.fromResponse(firstResult.challenge) + + // Re-create the same challenge WITHOUT expires, with a valid HMAC + const { expires: _, ...rest } = serverChallenge + const challengeNoExpires = Challenge.from({ + secretKey, + realm: rest.realm, + method: rest.method, + intent: rest.intent, + request: rest.request, + ...(rest.opaque && { meta: rest.opaque }), + }) + + const credential = Credential.from({ + challenge: challengeNoExpires, + payload: { signature: '0x123', type: 'transaction' }, + }) + + const result = await handle( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error() + + const body = (await result.challenge.json()) as { title: string; detail: string } + expect(body.title).toBe('Invalid Challenge') + expect(body.detail).toContain('missing required expires') + }) test('returns 402 when payload schema validation fails', async () => { const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({ amount: '1000', diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 6df90eb5..9a2df043 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -418,8 +418,19 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R } } - // Reject expired credentials - if (credential.challenge.expires && new Date(credential.challenge.expires) < new Date()) { + // Reject credentials without expires (fail-closed) or with expired timestamp + if (!credential.challenge.expires) { + const response = await transport.respondChallenge({ + challenge, + input, + error: new Errors.InvalidChallengeError({ + id: credential.challenge.id, + reason: 'missing required expires field', + }), + }) + return { challenge: response, status: 402 } + } + if (new Date(credential.challenge.expires) < new Date()) { const response = await transport.respondChallenge({ challenge, input, diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index eac11803..ba753b4d 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -66,7 +66,9 @@ export function charge(parameters: p const { challenge } = credential const { request } = challenge - if (challenge.expires && new Date(challenge.expires) < new Date()) + if (!challenge.expires) + throw new PaymentExpiredError() + if (new Date(challenge.expires) < new Date()) throw new PaymentExpiredError({ expires: challenge.expires }) const parsed = Methods.charge.schema.credential.payload.safeParse(credential.payload) diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index e99c8d45..2dd9a839 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -118,7 +118,8 @@ export function charge( const currency = challengeRequest.currency as `0x${string}` const recipient = challengeRequest.recipient as `0x${string}` - if (expires && new Date(expires) < new Date()) throw new PaymentExpiredError({ expires }) + if (!expires) throw new PaymentExpiredError() + if (new Date(expires) < new Date()) throw new PaymentExpiredError({ expires }) const memo = methodDetails?.memo as `0x${string}` | undefined From 2e4e7dc334c0bbfb08c8be3ef819d733c8990017 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 30 Mar 2026 09:35:47 -0700 Subject: [PATCH 2/5] test: add malformed expires guard and test - Reject credentials with unparseable expires (NaN date) as InvalidChallengeError - Guards added to core handler, Stripe verify, and Tempo verify - Test: 'returns 402 when credential challenge has malformed expires' --- src/server/Mppx.test.ts | 42 +++++++++++++++++++++++++++++++++++++ src/server/Mppx.ts | 11 ++++++++++ src/stripe/server/Charge.ts | 3 +++ src/tempo/server/Charge.ts | 4 +++- 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index 3e6f889d..eab12cd2 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -466,6 +466,48 @@ describe('request handler', () => { expect(body.title).toBe('Invalid Challenge') expect(body.detail).toContain('missing required expires') }) + test('returns 402 when credential challenge has malformed expires', async () => { + const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({ + amount: '1000', + currency: asset, + expires: new Date(Date.now() + 60_000).toISOString(), + recipient: accounts[0].address, + }) + + // Get a valid challenge from the server to capture the exact request shape + const firstResult = await handle(new Request('https://example.com/resource')) + expect(firstResult.status).toBe(402) + if (firstResult.status !== 402) throw new Error() + + const serverChallenge = Challenge.fromResponse(firstResult.challenge) + + // Re-create the challenge with a valid HMAC but inject a malformed expires + // by patching the challenge object after construction (bypasses zod at build time). + const challengeMalformed = { + ...serverChallenge, + expires: 'not-a-timestamp', + } + + const credential = Credential.from({ + challenge: challengeMalformed as any, + payload: { signature: '0x123', type: 'transaction' }, + }) + + // Credential.serialize does not re-validate, so the malformed expires + // reaches the server. Deserialization rejects it via zod schema. + const result = await handle( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error() + + const body = (await result.challenge.json()) as { title: string; detail: string } + expect(body.title).toBe('Malformed Credential') + }) + test('returns 402 when payload schema validation fails', async () => { const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({ amount: '1000', diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 9a2df043..3fd7ca16 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -430,6 +430,17 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R }) return { challenge: response, status: 402 } } + if (Number.isNaN(new Date(credential.challenge.expires).getTime())) { + const response = await transport.respondChallenge({ + challenge, + input, + error: new Errors.InvalidChallengeError({ + id: credential.challenge.id, + reason: 'malformed expires timestamp', + }), + }) + return { challenge: response, status: 402 } + } if (new Date(credential.challenge.expires) < new Date()) { const response = await transport.respondChallenge({ challenge, diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index ba753b4d..3b5f8b2e 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -1,5 +1,6 @@ import type * as Credential from '../../Credential.js' import { + InvalidChallengeError, PaymentActionRequiredError, PaymentExpiredError, VerificationFailedError, @@ -68,6 +69,8 @@ export function charge(parameters: p if (!challenge.expires) throw new PaymentExpiredError() + if (Number.isNaN(new Date(challenge.expires).getTime())) + throw new InvalidChallengeError({ id: challenge.id, reason: 'malformed expires timestamp' }) if (new Date(challenge.expires) < new Date()) throw new PaymentExpiredError({ expires: challenge.expires }) diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 2dd9a839..5edaaf9f 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -9,7 +9,7 @@ import { import { tempo as tempo_chain } from 'viem/chains' import { Abis, Transaction } from 'viem/tempo' -import { PaymentExpiredError } from '../../Errors.js' +import { InvalidChallengeError, PaymentExpiredError } from '../../Errors.js' import type { LooseOmit, NoExtraKeys } from '../../internal/types.js' import * as Method from '../../Method.js' import * as Store from '../../Store.js' @@ -119,6 +119,8 @@ export function charge( const recipient = challengeRequest.recipient as `0x${string}` if (!expires) throw new PaymentExpiredError() + if (Number.isNaN(new Date(expires).getTime())) + throw new InvalidChallengeError({ id: challenge.id, reason: 'malformed expires timestamp' }) if (new Date(expires) < new Date()) throw new PaymentExpiredError({ expires }) const memo = methodDetails?.memo as `0x${string}` | undefined From 2933b96147af9a5ac0e9a8987bdea14ff0981b0e Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 30 Mar 2026 09:40:23 -0700 Subject: [PATCH 3/5] style: fix formatter nit in Stripe Charge.ts --- src/stripe/server/Charge.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 3b5f8b2e..5a70c061 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -67,8 +67,7 @@ export function charge(parameters: p const { challenge } = credential const { request } = challenge - if (!challenge.expires) - throw new PaymentExpiredError() + if (!challenge.expires) throw new PaymentExpiredError() if (Number.isNaN(new Date(challenge.expires).getTime())) throw new InvalidChallengeError({ id: challenge.id, reason: 'malformed expires timestamp' }) if (new Date(challenge.expires) < new Date()) From e55fdc4bbfb27612c66af121e73ed19168ddcb3a Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 30 Mar 2026 14:10:13 -0700 Subject: [PATCH 4/5] refactor: unify expiry checks into Expires.assert helper --- src/Expires.ts | 19 +++++++++++++++++++ src/server/Mppx.ts | 30 ++++-------------------------- src/stripe/server/Charge.ts | 14 +++----------- src/tempo/server/Charge.ts | 7 ++----- 4 files changed, 28 insertions(+), 42 deletions(-) diff --git a/src/Expires.ts b/src/Expires.ts index b64a30be..29ea0b3f 100644 --- a/src/Expires.ts +++ b/src/Expires.ts @@ -1,3 +1,22 @@ +import { InvalidChallengeError, PaymentExpiredError } from './Errors.js' + +/** + * Asserts that `expires` is present, well-formed, and not in the past. + * + * Throws `InvalidChallengeError` when missing or malformed, + * and `PaymentExpiredError` when the timestamp is in the past. + */ +export function assert( + expires: string | undefined, + challengeId?: string, +): asserts expires is string { + if (!expires) + throw new InvalidChallengeError({ id: challengeId, reason: 'missing required expires field' }) + if (Number.isNaN(new Date(expires).getTime())) + throw new InvalidChallengeError({ id: challengeId, reason: 'malformed expires timestamp' }) + if (new Date(expires) < new Date()) throw new PaymentExpiredError({ expires }) +} + /** Returns an ISO 8601 datetime string `n` days from now. */ export function days(n: number) { return new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString() diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 3fd7ca16..59c5b4d2 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -419,35 +419,13 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R } // Reject credentials without expires (fail-closed) or with expired timestamp - if (!credential.challenge.expires) { - const response = await transport.respondChallenge({ - challenge, - input, - error: new Errors.InvalidChallengeError({ - id: credential.challenge.id, - reason: 'missing required expires field', - }), - }) - return { challenge: response, status: 402 } - } - if (Number.isNaN(new Date(credential.challenge.expires).getTime())) { - const response = await transport.respondChallenge({ - challenge, - input, - error: new Errors.InvalidChallengeError({ - id: credential.challenge.id, - reason: 'malformed expires timestamp', - }), - }) - return { challenge: response, status: 402 } - } - if (new Date(credential.challenge.expires) < new Date()) { + try { + Expires.assert(credential.challenge.expires, credential.challenge.id) + } catch (error) { const response = await transport.respondChallenge({ challenge, input, - error: new Errors.PaymentExpiredError({ - expires: credential.challenge.expires, - }), + error: error as Errors.PaymentError, }) return { challenge: response, status: 402 } } diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 5a70c061..096f05da 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -1,10 +1,6 @@ import type * as Credential from '../../Credential.js' -import { - InvalidChallengeError, - PaymentActionRequiredError, - PaymentExpiredError, - VerificationFailedError, -} from '../../Errors.js' +import { PaymentActionRequiredError, VerificationFailedError } from '../../Errors.js' +import * as Expires from '../../Expires.js' import type { LooseOmit, OneOf } from '../../internal/types.js' import * as Method from '../../Method.js' import type { StripeClient } from '../internal/types.js' @@ -67,11 +63,7 @@ export function charge(parameters: p const { challenge } = credential const { request } = challenge - if (!challenge.expires) throw new PaymentExpiredError() - if (Number.isNaN(new Date(challenge.expires).getTime())) - throw new InvalidChallengeError({ id: challenge.id, reason: 'malformed expires timestamp' }) - if (new Date(challenge.expires) < new Date()) - throw new PaymentExpiredError({ expires: challenge.expires }) + Expires.assert(challenge.expires, challenge.id) const parsed = Methods.charge.schema.credential.payload.safeParse(credential.payload) if (!parsed.success) throw new Error('Invalid credential payload: missing or malformed spt') diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 5edaaf9f..bfdb88b0 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -9,7 +9,7 @@ import { import { tempo as tempo_chain } from 'viem/chains' import { Abis, Transaction } from 'viem/tempo' -import { InvalidChallengeError, PaymentExpiredError } from '../../Errors.js' +import * as Expires from '../../Expires.js' import type { LooseOmit, NoExtraKeys } from '../../internal/types.js' import * as Method from '../../Method.js' import * as Store from '../../Store.js' @@ -118,10 +118,7 @@ export function charge( const currency = challengeRequest.currency as `0x${string}` const recipient = challengeRequest.recipient as `0x${string}` - if (!expires) throw new PaymentExpiredError() - if (Number.isNaN(new Date(expires).getTime())) - throw new InvalidChallengeError({ id: challenge.id, reason: 'malformed expires timestamp' }) - if (new Date(expires) < new Date()) throw new PaymentExpiredError({ expires }) + Expires.assert(expires, challenge.id) const memo = methodDetails?.memo as `0x${string}` | undefined From 2fbae5395fa31751c467d7a9e30f963dc70d2aba Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 30 Mar 2026 14:11:58 -0700 Subject: [PATCH 5/5] fix: align Expires.assert types with InvalidChallengeError.Options --- src/Expires.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Expires.ts b/src/Expires.ts index 29ea0b3f..34de8c19 100644 --- a/src/Expires.ts +++ b/src/Expires.ts @@ -11,9 +11,15 @@ export function assert( challengeId?: string, ): asserts expires is string { if (!expires) - throw new InvalidChallengeError({ id: challengeId, reason: 'missing required expires field' }) + throw new InvalidChallengeError({ + ...(challengeId && { id: challengeId }), + reason: 'missing required expires field', + }) if (Number.isNaN(new Date(expires).getTime())) - throw new InvalidChallengeError({ id: challengeId, reason: 'malformed expires timestamp' }) + throw new InvalidChallengeError({ + ...(challengeId && { id: challengeId }), + reason: 'malformed expires timestamp', + }) if (new Date(expires) < new Date()) throw new PaymentExpiredError({ expires }) }