From 325e81d2c7526e19021c161ee497bec67557aa31 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:05:03 +0200 Subject: [PATCH 1/3] fix: harden payment challenge handling --- .changeset/harden-challenge-binding.md | 5 ++ .env.example | 1 + README.md | 14 +++--- examples/charge-wagmi/server.ts | 2 +- examples/session/sse/src/server.ts | 2 +- examples/session/ws/src/server.ts | 2 +- examples/x402-mpp/src/app.ts | 2 +- src/Challenge.test.ts | 7 +++ src/Challenge.ts | 3 ++ src/cli/cli.test.ts | 30 +++++------ src/discovery/OpenApi.test.ts | 2 +- src/evm/PublicInterface.test-d.ts | 2 +- src/evm/server/Charge.test.ts | 2 +- src/mcp/client/McpClient.integration.test.ts | 2 +- src/mcp/client/McpClient.test.ts | 2 +- src/middlewares/elysia.test.ts | 2 +- src/middlewares/express.test.ts | 2 +- src/middlewares/hono.test.ts | 2 +- src/middlewares/internal/mppx.test.ts | 2 +- src/middlewares/nextjs.test.ts | 2 +- src/proxy/Proxy.test.ts | 2 +- src/proxy/services/anthropic.test.ts | 2 +- src/proxy/services/openai.test.ts | 2 +- src/proxy/services/stripe.test.ts | 2 +- src/server/Mppx.authorize.test.ts | 2 +- src/server/Mppx.test-d.ts | 2 +- src/server/Mppx.test.ts | 22 ++++++++- src/server/Mppx.ts | 15 +++++- src/server/Transport.test.ts | 2 +- src/stripe/Charge.integration.test.ts | 2 +- src/stripe/server/Charge.test.ts | 2 +- src/tempo/Subscription.integration.test.ts | 2 +- src/tempo/legacy/server/Session.test.ts | 52 ++++++++++---------- src/tempo/server/Charge.test.ts | 2 +- src/tempo/server/Subscription.test.ts | 2 +- src/tempo/session/server/Session.test.ts | 8 +-- src/x402/Exact.e2e.test.ts | 2 +- src/x402/PublicInterface.test-d.ts | 2 +- test/html/server.ts | 6 +-- 39 files changed, 134 insertions(+), 85 deletions(-) create mode 100644 .changeset/harden-challenge-binding.md diff --git a/.changeset/harden-challenge-binding.md b/.changeset/harden-challenge-binding.md new file mode 100644 index 00000000..9ee40637 --- /dev/null +++ b/.changeset/harden-challenge-binding.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Hardened server secret-key validation and capped oversized `WWW-Authenticate` request parameters. diff --git a/.env.example b/.env.example index 9f38fa5c..e338c29f 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ +# Generate with: openssl rand -base64 32 MPP_SECRET_KEY= # Examples diff --git a/README.md b/README.md index c3d438b8..31d5e8b6 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ export async function handler(request: Request) { } ``` +Generate `MPP_SECRET_KEY` with at least 32 bytes, for example: `openssl rand -base64 32`. + ### Client ```ts @@ -159,13 +161,13 @@ export const POST = proxy.fetch // Next.js This exposes the following routes: -| Route | Pricing | -| ---------------------------------- | ---------------------------- | -| `POST /openai/v1/chat/completions` | charge **$0.005** | +| Route | Pricing | +| ---------------------------------- | ----------------------------- | +| `POST /openai/v1/chat/completions` | charge **$0.005** | | `POST /openai/v1/completions` | session **$0.0001 per token** | -| `GET /openai/v1/models` | free | -| `POST /stripe/v1/charges` | charge **$0.01** | -| `GET /stripe/v1/customers/:id` | free | +| `GET /openai/v1/models` | free | +| `POST /stripe/v1/charges` | charge **$0.01** | +| `GET /stripe/v1/customers/:id` | free | ## Protocol diff --git a/examples/charge-wagmi/server.ts b/examples/charge-wagmi/server.ts index 65c23598..8ee11cb7 100644 --- a/examples/charge-wagmi/server.ts +++ b/examples/charge-wagmi/server.ts @@ -13,7 +13,7 @@ const mppx = Mppx.create({ testnet: true, }), ], - secretKey: 'test', + secretKey: 'test-secret-key-test-secret-key-32', }) export async function handler(request: Request): Promise { diff --git a/examples/session/sse/src/server.ts b/examples/session/sse/src/server.ts index 1f7f569e..f44dff8c 100644 --- a/examples/session/sse/src/server.ts +++ b/examples/session/sse/src/server.ts @@ -93,7 +93,7 @@ const store = Store.memory() // `Mppx.create()` requires a secret key so challenge IDs can be verified // statelessly. The example ships with a default demo key so `pnpm dev` works // out of the box, but still allows override via `MPP_SECRET_KEY`. -const secretKey = process.env.MPP_SECRET_KEY ?? 'mppx-demo-sse-secret' +const secretKey = process.env.MPP_SECRET_KEY ?? 'mppx-demo-sse-secret-key-minimum-32' // // `Mppx.create()` assembles the payment handler from method intents. diff --git a/examples/session/ws/src/server.ts b/examples/session/ws/src/server.ts index 33b9b64a..0e44114b 100644 --- a/examples/session/ws/src/server.ts +++ b/examples/session/ws/src/server.ts @@ -51,7 +51,7 @@ const pricePerToken = '0.000075' // `Mppx.create()` requires a secret key so challenge IDs can be verified // statelessly. The example ships with a default demo key so `pnpm dev` works // out of the box, but still allows override via `MPP_SECRET_KEY`. -const secretKey = process.env.MPP_SECRET_KEY ?? 'mppx-demo-websocket-secret' +const secretKey = process.env.MPP_SECRET_KEY ?? 'mppx-demo-websocket-secret-key-32' // Viem Client diff --git a/examples/x402-mpp/src/app.ts b/examples/x402-mpp/src/app.ts index bc2a3de7..4a1d4355 100644 --- a/examples/x402-mpp/src/app.ts +++ b/examples/x402-mpp/src/app.ts @@ -38,7 +38,7 @@ export function createApp(options: AppOptions) { x402: { facilitator }, }), ], - secretKey: options.secretKey ?? 'x402-mpp-example', + secretKey: options.secretKey ?? 'x402-mpp-example-secret-key-min-32', }) const paid = payments.compose( diff --git a/src/Challenge.test.ts b/src/Challenge.test.ts index b1226130..17b5183b 100644 --- a/src/Challenge.test.ts +++ b/src/Challenge.test.ts @@ -650,6 +650,13 @@ describe('deserialize', () => { ), ).toThrow('Missing request parameter.') }) + + test('error: rejects oversized request parameter before decoding', () => { + const oversizedRequest = 'a'.repeat(16 * 1024 + 1) + const header = `Payment id="a", realm="api", method="tempo", intent="charge", request="${oversizedRequest}"` + + expect(() => Challenge.deserialize(header)).toThrow('Request parameter too large.') + }) }) describe('fromHeaders', () => { diff --git a/src/Challenge.ts b/src/Challenge.ts index 1ac0fd4a..0b12da2e 100644 --- a/src/Challenge.ts +++ b/src/Challenge.ts @@ -7,6 +7,8 @@ import type * as Method from './Method.js' import * as PaymentRequest from './PaymentRequest.js' import * as z from './zod.js' +const maxRequestParameterLength = 16 * 1024 + /** * Schema for a payment challenge. * @@ -347,6 +349,7 @@ export function deserialize maxRequestParameterLength) throw new Error('Request parameter too large.') if (rest.method && !/^[a-z][a-z0-9:_-]*$/.test(rest.method)) throw new Error(`Invalid method: "${rest.method}". Must be lowercase per spec.`) diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index d355a1eb..f64dc7ea 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -442,7 +442,7 @@ describe('basic charge (examples/basic)', () => { const server = Mppx_server.create({ methods: [tempo.charge({ getClient: () => client })], realm: 'cli-test-basic', - secretKey: 'cli-test-secret', + secretKey: 'cli-test-secret-cli-test-secret-32', }) const httpServer = await Http.createServer(async (req, res) => { @@ -484,7 +484,7 @@ describe('basic charge (examples/basic)', () => { const server = Mppx_server.create({ methods: [unsupportedMethod, tempoMethod], realm: 'cli-test-multi-offer', - secretKey: 'cli-test-secret', + secretKey: 'cli-test-secret-cli-test-secret-32', }) const httpServer = await Http.createServer(async (req, res) => { @@ -563,7 +563,7 @@ describe('basic charge (examples/basic)', () => { const server = Mppx_server.create({ methods: [betaMethod, alphaMethod], realm: 'cli-test-config-offers', - secretKey: 'cli-test-secret', + secretKey: 'cli-test-secret-cli-test-secret-32', }) const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-cli-config-')) @@ -667,7 +667,7 @@ export default defineConfig({ const server = Mppx_server.create({ methods: [tempo.charge({ getClient: () => client })], realm: 'localhost', - secretKey: 'cli-test-secret', + secretKey: 'cli-test-secret-cli-test-secret-32', }) let authorization: string | undefined @@ -721,7 +721,7 @@ export default defineConfig({ }), ], realm: 'localhost', - secretKey: 'cli-test-secret', + secretKey: 'cli-test-secret-cli-test-secret-32', }) let authorization: string | undefined @@ -765,7 +765,7 @@ export default defineConfig({ const server = Mppx_server.create({ methods: [tempo.charge({ getClient: () => client })], realm: 'cli-test-no-account', - secretKey: 'cli-test-secret', + secretKey: 'cli-test-secret-cli-test-secret-32', }) const httpServer = await Http.createServer(async (req, res) => { @@ -816,7 +816,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => { }), ], realm: 'cli-test-multifetch', - secretKey: 'cli-test-secret', + secretKey: 'cli-test-secret-cli-test-secret-32', }) const httpServer = await Http.createServer(async (req, res) => { @@ -938,7 +938,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => { }), ], realm: 'cli-test-double-charge', - secretKey: 'cli-test-secret', + secretKey: 'cli-test-secret-cli-test-secret-32', }) // Track voucher cumulative amounts from credential payloads @@ -1001,7 +1001,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => { }), ], realm: 'cli-test-close-action', - secretKey: 'cli-test-secret', + secretKey: 'cli-test-secret-cli-test-secret-32', }) // Track the credential payload action from the close request @@ -1079,7 +1079,7 @@ describe('session sse (examples/session/sse)', () => { }), ], realm: 'cli-test-sse', - secretKey: 'cli-test-secret', + secretKey: 'cli-test-secret-cli-test-secret-32', }) const httpServer = await Http.createServer(async (req, res) => { @@ -1146,7 +1146,7 @@ describe('stripe charge', () => { }), ], realm: 'cli-test-stripe', - secretKey: 'cli-test-secret', + secretKey: 'cli-test-secret-cli-test-secret-32', }) const sptServer = await Http.createServer(async (_req, res) => { @@ -1186,7 +1186,7 @@ describe('stripe charge', () => { }), ], realm: 'cli-test-stripe-nokey', - secretKey: 'cli-test-secret', + secretKey: 'cli-test-secret-cli-test-secret-32', }) const appServer = await Http.createServer(async (req, res) => { @@ -1219,7 +1219,7 @@ describe('stripe charge', () => { }), ], realm: 'cli-test-stripe-live', - secretKey: 'cli-test-secret', + secretKey: 'cli-test-secret-cli-test-secret-32', }) const appServer = await Http.createServer(async (req, res) => { @@ -1670,7 +1670,7 @@ export default defineConfig({ const server = Mppx_server.create({ methods: [tempo.charge({ getClient: () => client })], realm: 'cli-sign-zero', - secretKey: 'cli-test-secret', + secretKey: 'cli-test-secret-cli-test-secret-32', }) const httpServer = await Http.createServer(async (req, res) => { @@ -1736,7 +1736,7 @@ export default defineConfig({ }), ], realm: 'cli-sign-zero-testnet', - secretKey: 'cli-test-secret', + secretKey: 'cli-test-secret-cli-test-secret-32', }) const httpServer = await Http.createServer(async (req, res) => { diff --git a/src/discovery/OpenApi.test.ts b/src/discovery/OpenApi.test.ts index 3d32f257..b495fdc7 100644 --- a/src/discovery/OpenApi.test.ts +++ b/src/discovery/OpenApi.test.ts @@ -73,7 +73,7 @@ function createMppx(methods: methods) { return Mppx.create({ methods, realm: 'test-realm', - secretKey: 'test-secret', + secretKey: 'test-secret-key-test-secret-key-32', }) } diff --git a/src/evm/PublicInterface.test-d.ts b/src/evm/PublicInterface.test-d.ts index 9a7d2f83..8b307e75 100644 --- a/src/evm/PublicInterface.test-d.ts +++ b/src/evm/PublicInterface.test-d.ts @@ -14,7 +14,7 @@ import { Mppx as ServerMppx } from '../server/index.js' const account = {} as Account const recipient = '0x209693Bc6afc0C5328bA36FaF03C514EF312287C' -const secretKey = 'test-secret' +const secretKey = 'test-secret-key-test-secret-key-32' const settle = async () => ({ reference: `0x${'1'.repeat(64)}` as `0x${string}`, }) diff --git a/src/evm/server/Charge.test.ts b/src/evm/server/Charge.test.ts index 7f353345..26f83937 100644 --- a/src/evm/server/Charge.test.ts +++ b/src/evm/server/Charge.test.ts @@ -45,7 +45,7 @@ describe('evm charge server', () => { }, }), ], - secretKey: 'test-secret', + secretKey: 'test-secret-key-test-secret-key-32', }) const client = ClientMppx.create({ methods: [ diff --git a/src/mcp/client/McpClient.integration.test.ts b/src/mcp/client/McpClient.integration.test.ts index a61055c5..93c34b7d 100644 --- a/src/mcp/client/McpClient.integration.test.ts +++ b/src/mcp/client/McpClient.integration.test.ts @@ -25,7 +25,7 @@ import * as McpServer_transport from '../server/Transport.js' import * as McpClient from './McpClient.js' const realm = 'api.example.com' -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' const chargeAmountRaw = 1_000_000n const doubleSessionAmountRaw = chargeAmountRaw * 2n const topUpAmountRaw = chargeAmountRaw * 3n diff --git a/src/mcp/client/McpClient.test.ts b/src/mcp/client/McpClient.test.ts index 9b7a05dd..6f96940b 100644 --- a/src/mcp/client/McpClient.test.ts +++ b/src/mcp/client/McpClient.test.ts @@ -14,7 +14,7 @@ import * as McpServer_transport from '../server/Transport.js' import * as McpClient from './McpClient.js' const realm = 'api.example.com' -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' function createChallenge() { return Challenge.fromMethod(Methods.charge, { diff --git a/src/middlewares/elysia.test.ts b/src/middlewares/elysia.test.ts index 81529331..9842a1bc 100644 --- a/src/middlewares/elysia.test.ts +++ b/src/middlewares/elysia.test.ts @@ -37,7 +37,7 @@ function createServer(app: Elysia) { }) } -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' describe('payment', () => { test('short-circuits management responses', async () => { diff --git a/src/middlewares/express.test.ts b/src/middlewares/express.test.ts index d7f304b7..34514fa0 100644 --- a/src/middlewares/express.test.ts +++ b/src/middlewares/express.test.ts @@ -23,7 +23,7 @@ function createServer(app: express.Express) { }) } -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' function createChargeHarness(feePayer: boolean) { const mppx = Mppx.create({ diff --git a/src/middlewares/hono.test.ts b/src/middlewares/hono.test.ts index 5ff84301..378fc2fb 100644 --- a/src/middlewares/hono.test.ts +++ b/src/middlewares/hono.test.ts @@ -35,7 +35,7 @@ function createServer(app: Hono) { }) } -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' describe('payment', () => { test('short-circuits management responses', async () => { diff --git a/src/middlewares/internal/mppx.test.ts b/src/middlewares/internal/mppx.test.ts index 75295c3b..ff626ace 100644 --- a/src/middlewares/internal/mppx.test.ts +++ b/src/middlewares/internal/mppx.test.ts @@ -5,7 +5,7 @@ import { describe, expect, test } from 'vp/test' import { wrap } from './mppx.js' const realm = 'api.example.com' -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' const mockChargeA = Method.from({ name: 'alpha', diff --git a/src/middlewares/nextjs.test.ts b/src/middlewares/nextjs.test.ts index 51fff0a1..cc7af7ce 100644 --- a/src/middlewares/nextjs.test.ts +++ b/src/middlewares/nextjs.test.ts @@ -36,7 +36,7 @@ function createServer(handler: (request: Request) => Promise | Respons }) } -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' describe('payment', () => { test('short-circuits management responses', async () => { diff --git a/src/proxy/Proxy.test.ts b/src/proxy/Proxy.test.ts index a9457c8b..d8f55f75 100644 --- a/src/proxy/Proxy.test.ts +++ b/src/proxy/Proxy.test.ts @@ -15,7 +15,7 @@ import * as Service from './Service.js' import { anthropic } from './services/anthropic.js' import { openai } from './services/openai.js' -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' const isLocalnet = tempoNetwork === 'localnet' const mppx_server = Mppx_server.create({ diff --git a/src/proxy/services/anthropic.test.ts b/src/proxy/services/anthropic.test.ts index e97a8c25..eb62348b 100644 --- a/src/proxy/services/anthropic.test.ts +++ b/src/proxy/services/anthropic.test.ts @@ -9,7 +9,7 @@ import * as ApiProxy from '../Proxy.js' import { anthropic } from './anthropic.js' const apiKey = 'sk-ant-test-fake-anthropic-key' -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' const mppx_server = Mppx_server.create({ methods: [ diff --git a/src/proxy/services/openai.test.ts b/src/proxy/services/openai.test.ts index efab9e64..1c9b5272 100644 --- a/src/proxy/services/openai.test.ts +++ b/src/proxy/services/openai.test.ts @@ -11,7 +11,7 @@ import { openai } from './openai.js' const apiKey = process.env.VITE_OPENAI_API_KEY if (!apiKey) console.warn('OPENAI_API_KEY not set — openai proxy tests will be skipped') -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' const mppx_server = Mppx_server.create({ methods: [ diff --git a/src/proxy/services/stripe.test.ts b/src/proxy/services/stripe.test.ts index 059bf8c8..69e04d48 100644 --- a/src/proxy/services/stripe.test.ts +++ b/src/proxy/services/stripe.test.ts @@ -9,7 +9,7 @@ import * as ApiProxy from '../Proxy.js' import { stripe } from './stripe.js' const apiKey = 'sk_test_fake_stripe_key' -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' const mppx_server = Mppx_server.create({ methods: [ diff --git a/src/server/Mppx.authorize.test.ts b/src/server/Mppx.authorize.test.ts index d238e3ee..7c946e3d 100644 --- a/src/server/Mppx.authorize.test.ts +++ b/src/server/Mppx.authorize.test.ts @@ -4,7 +4,7 @@ import { describe, expect, test } from 'vp/test' import * as Http from '~test/Http.js' const realm = 'api.example.com' -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' function successReceipt(method = 'mock') { return { diff --git a/src/server/Mppx.test-d.ts b/src/server/Mppx.test-d.ts index e1a5f80d..7a677c05 100644 --- a/src/server/Mppx.test-d.ts +++ b/src/server/Mppx.test-d.ts @@ -95,7 +95,7 @@ const legacySessionMethod = Method.toServer(mockSession, { }, }) -const secretKey = 'test-secret' +const secretKey = 'test-secret-key-test-secret-key-32' const realm = 'api.example.com' describe('Mppx type tests', () => { diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index 1990b19a..091410f0 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -10,7 +10,7 @@ import { Types as evm_Types } from 'mppx/evm' import { evm, Mppx, stripe, Store, Transport, tempo } from 'mppx/server' import { Header as x402_Header, Types as x402_Types, type PaymentPayload } from 'mppx/x402' import { getTransactionReceipt } from 'viem/actions' -import { describe, expect, test } from 'vp/test' +import { describe, expect, test, vi } from 'vp/test' import * as Http from '~test/Http.js' import { deployEscrow } from '~test/tempo/legacy/session.js' import { accounts, asset, client } from '~test/tempo/viem.js' @@ -19,7 +19,7 @@ import type { SessionReceipt } from '../tempo/session/precompile/Protocol.js' import * as x402_RouteBinding from '../x402/internal/RouteBinding.js' const realm = 'api.example.com' -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' const method = tempo({ getClient: () => client, @@ -40,6 +40,24 @@ describe('create', () => { expect(handler.transport.name).toBe('mcp') }) + + test('error: rejects short explicit secret key', () => { + expect(() => Mppx.create({ methods: [method], realm, secretKey: 'short' })).toThrow( + 'Secret key must be at least 32 bytes.', + ) + }) + + test('error: rejects short MPP_SECRET_KEY secret key', () => { + vi.stubEnv('MPP_SECRET_KEY', 'short') + + try { + expect(() => Mppx.create({ methods: [method], realm })).toThrow( + 'Secret key must be at least 32 bytes.', + ) + } finally { + vi.unstubAllEnvs() + } + }) }) describe('request handler', () => { diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 74574a61..343c4879 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -22,6 +22,9 @@ import * as NodeListener from './NodeListener.js' import * as Request from './Request.js' import * as Transport from './Transport.js' +const minimumSecretKeyBytes = 32 +const secretKeyGenerationCommand = 'openssl rand -base64 32' + export type Methods = readonly (Method.AnyServer | readonly Method.AnyServer[])[] export type ServerEventMap< @@ -431,6 +434,7 @@ export function create< 'Missing secret key. Set the MPP_SECRET_KEY environment variable or pass `secretKey` to Mppx.create().', ) } + assertSecretKey(secretKey) const methods = config.methods.flat() as unknown as FlattenMethods const serverEvents = createServerEventDispatcher, transport>() @@ -762,6 +766,15 @@ export function create< } as never } +function assertSecretKey(secretKey: string) { + const byteLength = new TextEncoder().encode(secretKey).byteLength + if (byteLength >= minimumSecretKeyBytes) return + + throw new Error( + `Secret key must be at least ${minimumSecretKeyBytes} bytes. Generate one with \`${secretKeyGenerationCommand}\` and set MPP_SECRET_KEY or pass it to Mppx.create().`, + ) +} + export declare namespace create { type Config< methods extends Methods = Methods, @@ -771,7 +784,7 @@ export declare namespace create { methods: methods /** Server realm (e.g., hostname). Resolution order: explicit value > env vars (`MPP_REALM`, `FLY_APP_NAME`, `VERCEL_URL`, etc.) > request URL hostname > `"MPP Payment"`. */ realm?: string | undefined - /** Secret key for HMAC-bound challenge IDs for stateless verification. Auto-detected from `MPP_SECRET_KEY` environment variable. Throws if neither provided nor set. */ + /** Secret key for HMAC-bound challenge IDs for stateless verification. Must be at least 32 bytes. Auto-detected from `MPP_SECRET_KEY` environment variable. */ secretKey?: string | undefined /** Transport to use. @default Transport.http() */ transport?: transport | undefined diff --git a/src/server/Transport.test.ts b/src/server/Transport.test.ts index d28f1158..6ad9ff39 100644 --- a/src/server/Transport.test.ts +++ b/src/server/Transport.test.ts @@ -6,7 +6,7 @@ import { describe, expect, test } from 'vp/test' import { BadRequestError, ChannelClosedError } from '../Errors.js' const realm = 'api.example.com' -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' const challenge = Challenge.fromMethod(Methods.charge, { realm, diff --git a/src/stripe/Charge.integration.test.ts b/src/stripe/Charge.integration.test.ts index f03ad5c9..4fa90a46 100644 --- a/src/stripe/Charge.integration.test.ts +++ b/src/stripe/Charge.integration.test.ts @@ -7,7 +7,7 @@ import * as Http from '~test/Http.js' const stripeSecretKey = process.env.VITE_STRIPE_SECRET_KEY const realm = 'api.example.com' -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' let httpServer: Awaited> | undefined diff --git a/src/stripe/server/Charge.test.ts b/src/stripe/server/Charge.test.ts index e65526a4..25b1a764 100644 --- a/src/stripe/server/Charge.test.ts +++ b/src/stripe/server/Charge.test.ts @@ -7,7 +7,7 @@ import type { StripeClient } from '../internal/types.js' import type { charge as StripeCharge } from './Charge.js' const realm = 'api.example.com' -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' let httpServer: Awaited> | undefined diff --git a/src/tempo/Subscription.integration.test.ts b/src/tempo/Subscription.integration.test.ts index 94255c76..31906379 100644 --- a/src/tempo/Subscription.integration.test.ts +++ b/src/tempo/Subscription.integration.test.ts @@ -10,7 +10,7 @@ import * as SubscriptionStore from './subscription/Store.js' import type { SubscriptionAccessKey, SubscriptionRecord } from './subscription/Types.js' const realm = 'news.example.com' -const secretKey = 'subscription-lifecycle-secret' +const secretKey = 'subscription-lifecycle-secret-key-32' const currency = '0x20c0000000000000000000000000000000000001' const recipient = '0x1234567890abcdef1234567890abcdef12345678' const periodCount = '30' diff --git a/src/tempo/legacy/server/Session.test.ts b/src/tempo/legacy/server/Session.test.ts index 06a4730f..c9eee36d 100644 --- a/src/tempo/legacy/server/Session.test.ts +++ b/src/tempo/legacy/server/Session.test.ts @@ -123,7 +123,7 @@ describe.runIf(isLocalnet)('session', () => { } as session.Parameters), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }) } @@ -4211,7 +4211,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -4253,7 +4253,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -4296,7 +4296,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -4347,7 +4347,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -4390,7 +4390,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }) const result = await handler.session({ @@ -4434,7 +4434,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }) const result = await handler.session({ @@ -4464,7 +4464,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) let voucherPosts = 0 @@ -4542,7 +4542,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'request' }) let voucherPosts = 0 @@ -4627,7 +4627,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) @@ -4710,7 +4710,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) let voucherPosts = 0 @@ -4784,7 +4784,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -4825,7 +4825,7 @@ describe.runIf(isLocalnet)('session', () => { const handler = Mppx_server.create({ methods: [tempo_server.charge({ account: accounts[0], currency: asset })], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }) const result = await handler.charge({ @@ -4855,7 +4855,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) let voucherUpdates = 0 @@ -4968,7 +4968,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) let voucherUpdates = 0 @@ -5081,7 +5081,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) const route = (request: Request) => routeHandler(request) @@ -5191,7 +5191,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) const httpHandler = NodeRequest.toNodeListener(async (request) => { @@ -5278,7 +5278,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) const route = (request: Request) => routeHandler(request) @@ -5371,7 +5371,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) const httpHandler = NodeRequest.toNodeListener(async (request) => { @@ -5480,7 +5480,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) const route = (request: Request) => routeHandler(request) @@ -5582,7 +5582,7 @@ describe.runIf(isLocalnet)('session', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 6, unitType: 'token' }) const httpHandler = NodeRequest.toNodeListener(async (request) => { @@ -6041,7 +6041,7 @@ describe('session default currency resolution', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }) const result = await (handler.session as Function)({ @@ -6067,7 +6067,7 @@ describe('session default currency resolution', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }) const result = await (handler.session as Function)({ @@ -6094,7 +6094,7 @@ describe('session default currency resolution', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }) const result = await (handler.session as Function)({ @@ -6122,7 +6122,7 @@ describe('session default currency resolution', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }) const result = await handler.session({ @@ -6149,7 +6149,7 @@ describe('session default currency resolution', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }) expect(() => diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index 54142fee..2ac24f3e 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -29,7 +29,7 @@ import * as defaults from '../internal/defaults.js' import * as Proof from '../internal/proof.js' const realm = 'api.example.com' -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' function isPairAlreadyExistsError(error: unknown) { if (typeof error !== 'object' || error === null) return false diff --git a/src/tempo/server/Subscription.test.ts b/src/tempo/server/Subscription.test.ts index c1fed1e2..da0277ba 100644 --- a/src/tempo/server/Subscription.test.ts +++ b/src/tempo/server/Subscription.test.ts @@ -15,7 +15,7 @@ import type { SubscriptionRecord } from '../subscription/Types.js' import { renew, subscription } from './Subscription.js' const realm = 'api.example.com' -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' const activeBillingAnchor = new Date(Math.floor(Date.now() / 1_000) * 1_000).toISOString() const activeSubscriptionExpires = new Date( Math.ceil((Date.now() + 365 * 24 * 60 * 60 * 1_000) / 1_000) * 1_000, diff --git a/src/tempo/session/server/Session.test.ts b/src/tempo/session/server/Session.test.ts index c23c760f..0a589ea9 100644 --- a/src/tempo/session/server/Session.test.ts +++ b/src/tempo/session/server/Session.test.ts @@ -1815,7 +1815,7 @@ describe('precompile server session unit guardrails', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 0, unitType: 'request' }) } @@ -1996,7 +1996,7 @@ describe('precompile server session unit guardrails', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 0, unitType: 'request' }) } @@ -2163,7 +2163,7 @@ describe('precompile server session unit guardrails', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount, decimals: 0, suggestedDeposit: maxDeposit.toString(), unitType }) const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -2312,7 +2312,7 @@ describe('precompile server session unit guardrails', () => { }), ], realm: 'api.example.com', - secretKey: 'secret', + secretKey: 'test-secret-key-test-secret-key-32', }).session({ amount: '1', decimals: 0, suggestedDeposit: maxDeposit.toString() }) const route = async (request: Request) => { diff --git a/src/x402/Exact.e2e.test.ts b/src/x402/Exact.e2e.test.ts index b95c1c07..ee374f93 100644 --- a/src/x402/Exact.e2e.test.ts +++ b/src/x402/Exact.e2e.test.ts @@ -8,7 +8,7 @@ import * as Header from './Header.js' import * as RouteBinding from './internal/RouteBinding.js' import * as Types from './Types.js' -const secretKey = 'test-secret' +const secretKey = 'test-secret-key-test-secret-key-32' const transaction = `0x${'1'.repeat(64)}` describe('x402 exact e2e', () => { diff --git a/src/x402/PublicInterface.test-d.ts b/src/x402/PublicInterface.test-d.ts index 84e7262f..d75f013f 100644 --- a/src/x402/PublicInterface.test-d.ts +++ b/src/x402/PublicInterface.test-d.ts @@ -4,7 +4,7 @@ import { describe, expectTypeOf, test } from 'vp/test' import { evm as clientEvm } from '../client/index.js' -const secretKey = 'test-secret' +const secretKey = 'test-secret-key-test-secret-key-32' describe('x402 public interface', () => { test('server evm charge accepts known assets without transfer metadata', () => { diff --git a/test/html/server.ts b/test/html/server.ts index fcebf13d..8318193c 100644 --- a/test/html/server.ts +++ b/test/html/server.ts @@ -39,7 +39,7 @@ export async function startServer(port: number): Promise { testnet: true, }), ], - secretKey: 'test-html-server-secret-key', + secretKey: 'test-html-server-secret-key-32-byte', }) const tempoCustomTextMppx = Mppx.create({ methods: [ @@ -52,7 +52,7 @@ export async function startServer(port: number): Promise { testnet: true, }), ], - secretKey: 'test-html-server-secret-key', + secretKey: 'test-html-server-secret-key-32-byte', }) const stripeMppx = stripeEnabled ? Mppx.create({ @@ -75,7 +75,7 @@ export async function startServer(port: number): Promise { secretKey: stripeSecretKey!, }), ], - secretKey: 'test-html-server-secret-key', + secretKey: 'test-html-server-secret-key-32-byte', }) : undefined From 3f06ffea9bd91592eeea60ca1868e6dfa13cf044 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:15:27 +0200 Subject: [PATCH 2/3] test: update secret key fixtures --- src/client/Mppx.test.ts | 2 +- src/client/internal/Fetch.test.ts | 2 +- src/server/Transport.test.ts | 10 +++++----- src/stripe/client/Charge.test.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/client/Mppx.test.ts b/src/client/Mppx.test.ts index d24a4349..718e898a 100644 --- a/src/client/Mppx.test.ts +++ b/src/client/Mppx.test.ts @@ -7,7 +7,7 @@ import * as Http from '~test/Http.js' import { accounts, asset, client } from '~test/tempo/viem.js' const realm = 'api.example.com' -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' afterEach(() => { Mppx.restore() diff --git a/src/client/internal/Fetch.test.ts b/src/client/internal/Fetch.test.ts index de9ef249..b417258e 100644 --- a/src/client/internal/Fetch.test.ts +++ b/src/client/internal/Fetch.test.ts @@ -10,7 +10,7 @@ import { accounts, asset, chain, client, http } from '~test/tempo/viem.js' import * as Fetch from './Fetch.js' const realm = 'api.example.com' -const secretKey = 'test-secret-key' +const secretKey = 'test-secret-key-test-secret-key-32' const server = Mppx_server.create({ methods: [ diff --git a/src/server/Transport.test.ts b/src/server/Transport.test.ts index 6ad9ff39..cace31cf 100644 --- a/src/server/Transport.test.ts +++ b/src/server/Transport.test.ts @@ -65,7 +65,7 @@ describe('http', () => { { "challenge": { "expires": "2025-01-01T00:00:00.000Z", - "id": "QNLtjAvrKKR0VlEGSIowhULqcGlCDU4fjrP-O7js8XE", + "id": "ITdnfSy5EVxmsDHMll-mcEbGENBvnz3jfySVS8uFS7Y", "intent": "charge", "method": "tempo", "realm": "api.example.com", @@ -114,7 +114,7 @@ describe('http', () => { { "headers": { "cache-control": "no-store", - "www-authenticate": "Payment id="QNLtjAvrKKR0VlEGSIowhULqcGlCDU4fjrP-O7js8XE", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwMDAwMDAwIiwiY3VycmVuY3kiOiIweDIwYzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDEiLCJyZWNpcGllbnQiOiIweDc0MmQzNUNjNjYzNEMwNTMyOTI1YTNiODQ0QmM5ZTc1OTVmOGZFMDAifQ", expires="2025-01-01T00:00:00.000Z"", + "www-authenticate": "Payment id="ITdnfSy5EVxmsDHMll-mcEbGENBvnz3jfySVS8uFS7Y", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwMDAwMDAwIiwiY3VycmVuY3kiOiIweDIwYzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDEiLCJyZWNpcGllbnQiOiIweDc0MmQzNUNjNjYzNEMwNTMyOTI1YTNiODQ0QmM5ZTc1OTVmOGZFMDAifQ", expires="2025-01-01T00:00:00.000Z"", }, "status": 402, } @@ -477,7 +477,7 @@ describe('mcp', () => { { "challenge": { "expires": "2025-01-01T00:00:00.000Z", - "id": "QNLtjAvrKKR0VlEGSIowhULqcGlCDU4fjrP-O7js8XE", + "id": "ITdnfSy5EVxmsDHMll-mcEbGENBvnz3jfySVS8uFS7Y", "intent": "charge", "method": "tempo", "realm": "api.example.com", @@ -514,7 +514,7 @@ describe('mcp', () => { "challenges": [ { "expires": "2025-01-01T00:00:00.000Z", - "id": "QNLtjAvrKKR0VlEGSIowhULqcGlCDU4fjrP-O7js8XE", + "id": "ITdnfSy5EVxmsDHMll-mcEbGENBvnz3jfySVS8uFS7Y", "intent": "charge", "method": "tempo", "realm": "api.example.com", @@ -560,7 +560,7 @@ describe('mcp', () => { "result": { "_meta": { "org.paymentauth/receipt": { - "challengeId": "QNLtjAvrKKR0VlEGSIowhULqcGlCDU4fjrP-O7js8XE", + "challengeId": "ITdnfSy5EVxmsDHMll-mcEbGENBvnz3jfySVS8uFS7Y", "method": "tempo", "reference": "0xtxhash", "status": "success", diff --git a/src/stripe/client/Charge.test.ts b/src/stripe/client/Charge.test.ts index bbbb7ab9..6c1f4b1a 100644 --- a/src/stripe/client/Charge.test.ts +++ b/src/stripe/client/Charge.test.ts @@ -7,7 +7,7 @@ import type { StripeJs } from '../internal/types.js' import { charge as clientCharge_ } from './Charge.js' const realm = 'api.example.com' -const secretKey = 'test-hmac-key' +const secretKey = 'test-secret-key-test-secret-key-32' const dummyClientCharge = clientCharge_({ createToken: async () => 'spt_test', From 454c9e41734a60cbbfbcba0272f5f1f9f3bac728 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:37:18 +0200 Subject: [PATCH 3/3] perf: parse quoted challenge params linearly --- src/Challenge.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Challenge.ts b/src/Challenge.ts index 0b12da2e..ff1aadc8 100644 --- a/src/Challenge.ts +++ b/src/Challenge.ts @@ -444,26 +444,36 @@ function readQuotedAuthParamValue( start: number, ): [value: string, nextIndex: number] { let i = start - let value = '' let escaped = false + let segmentStart = start + let parts: string[] | undefined while (i < input.length) { const char = input[i]! i++ if (escaped) { - value += char + parts ??= [] + parts.push(char) + segmentStart = i escaped = false continue } if (char === '\\') { + parts ??= [] + parts.push(input.slice(segmentStart, i - 1)) + segmentStart = i escaped = true continue } - if (char === '"') return [value, i] - value += char + if (char === '"') { + const value = parts + ? [...parts, input.slice(segmentStart, i - 1)].join('') + : input.slice(start, i - 1) + return [value, i] + } } throw new Error('Unterminated quoted-string.')