Skip to content
Open
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
35 changes: 35 additions & 0 deletions src/internal/NonceSet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NonceSet } from './NonceSet.js'

describe('NonceSet', () => {
test('returns false for unknown nonce', () => {
const set = new NonceSet()
expect(set.has('unknown')).toBe(false)
})

test('returns true for added nonce', () => {
const set = new NonceSet()
set.add('nonce-1')
expect(set.has('nonce-1')).toBe(true)
})

test('returns false for expired nonce', () => {
const set = new NonceSet()
const pastExpires = new Date(Date.now() - 1000).toISOString()
set.add('expired', pastExpires)
expect(set.has('expired')).toBe(false)
})

test('returns true for non-expired nonce', () => {
const set = new NonceSet()
const futureExpires = new Date(Date.now() + 60_000).toISOString()
set.add('valid', futureExpires)
expect(set.has('valid')).toBe(true)
})

test('different nonces are independent', () => {
const set = new NonceSet()
set.add('nonce-a')
expect(set.has('nonce-a')).toBe(true)
expect(set.has('nonce-b')).toBe(false)
})
})
45 changes: 45 additions & 0 deletions src/internal/NonceSet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/** Default TTL for nonces without an explicit expiration (5 minutes). */
const DEFAULT_TTL = 5 * 60_000

/** Eviction threshold — run cleanup when map exceeds this size. */
const EVICTION_THRESHOLD = 10_000

/**
* In-memory set for tracking used nonces with TTL-based eviction.
*
* Used for replay prevention — once a nonce is added, `has()` returns
* `true` until the TTL expires.
*/
export class NonceSet {
private entries = new Map<string, number>()

/** Returns `true` if the nonce has been recorded and has not expired. */
has(nonce: string): boolean {
const expiry = this.entries.get(nonce)
if (expiry === undefined) return false
if (Date.now() > expiry) {
this.entries.delete(nonce)
return false
}
return true
}

/**
* Records a nonce with an expiration time.
*
* @param nonce - The nonce to record.
* @param expires - Optional ISO 8601 expiration. If not provided, uses a default TTL.
*/
add(nonce: string, expires?: string): void {
const expiry = expires ? new Date(expires).getTime() : Date.now() + DEFAULT_TTL
this.entries.set(nonce, expiry)
if (this.entries.size > EVICTION_THRESHOLD) this.evict()
}

private evict(): void {
const now = Date.now()
for (const [key, expiry] of this.entries) {
if (now > expiry) this.entries.delete(key)
}
}
}
112 changes: 112 additions & 0 deletions src/server/Mppx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,32 @@ const method = tempo({
getClient: () => client,
})

const replayBaseMethod = Method.from({
name: 'mock',
intent: 'charge',
schema: {
credential: {
payload: z.object({ token: z.string() }),
},
request: z.object({
amount: z.string(),
currency: z.string(),
recipient: z.string(),
}),
},
})

const replayMethod = Method.toServer(replayBaseMethod, {
async verify() {
return {
method: 'mock',
reference: 'tx-ref',
status: 'success' as const,
timestamp: new Date().toISOString(),
}
},
})

describe('create', () => {
test('default', () => {
const handler = Mppx.create({ methods: [method], realm, secretKey })
Expand All @@ -25,6 +51,39 @@ describe('create', () => {

expect(handler.transport.name).toBe('mcp')
})

test('injects a unique internal nonce into each issued challenge id', async () => {
const handler = Mppx.create({ methods: [replayMethod], realm, secretKey })

const issue = () =>
handler.charge({
amount: '1',
currency: asset,
recipient: accounts[0].address,
})(new Request('https://example.com/resource'))

const [result1, result2] = await Promise.all([issue(), issue()])
expect(result1.status).toBe(402)
expect(result2.status).toBe(402)
if (result1.status !== 402 || result2.status !== 402) throw new Error()

const challenge1 = Challenge.fromResponse(result1.challenge)
const challenge2 = Challenge.fromResponse(result2.challenge)
expect(challenge1.id).not.toBe(challenge2.id)
})

test('rejects reserved internal meta key', () => {
const handler = Mppx.create({ methods: [replayMethod], realm, secretKey })

expect(() =>
handler.charge({
amount: '1',
currency: asset,
recipient: accounts[0].address,
meta: { mppx_challenge_nonce: 'reserved' },
}),
).toThrow('Reserved meta key "mppx_challenge_nonce" is not allowed.')
})
})

describe('request handler', () => {
Expand Down Expand Up @@ -138,6 +197,59 @@ describe('request handler', () => {
`)
})

test('returns 402 when the same challenge credential is replayed', async () => {
let verifyCount = 0
const countedMethod = Method.toServer(replayBaseMethod, {
async verify() {
verifyCount += 1
return {
method: 'mock',
reference: 'tx-ref',
status: 'success' as const,
timestamp: new Date().toISOString(),
}
},
})
const handler = Mppx.create({ methods: [countedMethod], realm, secretKey })
const charge = handler.charge({
amount: '1',
currency: asset,
recipient: accounts[0].address,
})

const challengeResponse = await charge(new Request('https://example.com/resource'))
expect(challengeResponse.status).toBe(402)
if (challengeResponse.status !== 402) throw new Error()

const challenge = Challenge.fromResponse(challengeResponse.challenge)
const auth = Credential.serialize(
Credential.from({
challenge,
payload: { token: 'valid' },
}),
)

const success = await charge(
new Request('https://example.com/resource', {
headers: { Authorization: auth },
}),
)
expect(success.status).toBe(200)
expect(verifyCount).toBe(1)

const replay = await charge(
new Request('https://example.com/resource', {
headers: { Authorization: auth },
}),
)
expect(replay.status).toBe(402)
expect(verifyCount).toBe(1)
if (replay.status !== 402) throw new Error()

const body = (await replay.challenge.json()) as { detail: string }
expect(body.detail).toContain('credential has already been used')
})

test('returns 402 when credential is from a different route (cross-route scope confusion)', async () => {
const handler = Mppx.create({ methods: [method], realm, secretKey })

Expand Down
31 changes: 29 additions & 2 deletions src/server/Mppx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as Credential from '../Credential.js'
import * as Errors from '../Errors.js'
import * as Expires from '../Expires.js'
import * as Env from '../internal/env.js'
import { NonceSet } from '../internal/NonceSet.js'
import type * as Method from '../Method.js'
import * as PaymentRequest from '../PaymentRequest.js'
import type * as Receipt from '../Receipt.js'
Expand Down Expand Up @@ -165,6 +166,7 @@ export function create<
}

const methods = config.methods.flat() as unknown as FlattenMethods<methods>
const nonceSet = new NonceSet()

const handlers: Record<string, unknown> = {}
const intentCount: Record<string, number> = {}
Expand All @@ -174,6 +176,7 @@ export function create<
handlers[`${mi.name}/${mi.intent}`] = createMethodFn({
defaults: mi.defaults,
method: mi,
nonceSet,
realm,
request: mi.request as never,
respond: mi.respond as never,
Expand Down Expand Up @@ -253,10 +256,14 @@ function createMethodFn<
): createMethodFn.ReturnType<method, transport, defaults>
// biome-ignore lint/correctness/noUnusedVariables: _
function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.ReturnType {
const { defaults, method, realm, respond, secretKey, transport, verify } = parameters
const { defaults, method, nonceSet, realm, respond, secretKey, transport, verify } = parameters

const internalMetaKey = 'mppx_challenge_nonce'

return (options) => {
const { description, meta, ...rest } = options
if (meta && internalMetaKey in meta)
throw new Error(`Reserved meta key "${internalMetaKey}" is not allowed.`)
const merged = { ...defaults, ...rest }

return Object.assign(
Expand Down Expand Up @@ -289,7 +296,12 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
const challenge = Challenge.fromMethod(method, {
description,
expires,
meta,
meta: {
...(meta ?? {}),
[internalMetaKey]: Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) =>
b.toString(16).padStart(2, '0'),
).join(''),
},
realm,
request,
secretKey,
Expand All @@ -315,6 +327,18 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
return { challenge: response, status: 402 }
}

if (nonceSet.has(credential.challenge.id)) {
const response = await transport.respondChallenge({
challenge,
input,
error: new Errors.InvalidChallengeError({
id: credential.challenge.id,
reason: 'credential has already been used',
}),
})
return { challenge: response, status: 402 }
}

// Verify the echoed challenge was issued by us by recomputing its HMAC.
// This is stateless—no database lookup needed.
if (!Challenge.verify(credential.challenge, { secretKey })) {
Expand Down Expand Up @@ -433,6 +457,8 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
return { challenge: response, status: 402 }
}

nonceSet.add(credential.challenge.id, credential.challenge.expires)

// If the method's `respond` hook returns a Response, it means this
// request is a management action (e.g. channel open, voucher POST)
// and the user's route handler should NOT run. `withReceipt()` will
Expand Down Expand Up @@ -483,6 +509,7 @@ declare namespace createMethodFn {
> = {
defaults?: defaults
method: method
nonceSet: NonceSet
realm: string
request?: Method.RequestFn<method>
respond?: Method.RespondFn<method>
Expand Down
38 changes: 29 additions & 9 deletions src/tempo/Attribution.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Bytes, Hash, Hex } from 'ox'
import { Base64, Bytes, Hash, Hex } from 'ox'
import { describe, expect, test } from 'vitest'

import * as Attribution from './Attribution.js'

const challengeId = 'aAY7_IEDzsznNYplhOSE8cERQxvjFcT4Lcn-7FHjLVE'

describe('Attribution', () => {
describe('tag', () => {
test('is a 4-byte hex string', () => {
Expand All @@ -29,10 +31,11 @@ describe('Attribution', () => {
expect(version).toBe('01')
})

test('generates unique memos (random nonce)', () => {
const a = Attribution.encode({ serverId: 'api.example.com' })
const b = Attribution.encode({ serverId: 'api.example.com' })
expect(a).not.toBe(b)
test('encodes challenge-bound suffix when challengeId is provided', () => {
const memo = Attribution.encode({ challengeId, serverId: 'api.example.com' })
const suffix = `0x${memo.slice(52)}` as `0x${string}`
const expected = Hex.slice(Base64.toHex(challengeId), 0, 7)
expect(suffix.toLowerCase()).toBe(expected.toLowerCase())
})

test('encodes server fingerprint from serverId', () => {
Expand Down Expand Up @@ -137,6 +140,20 @@ describe('Attribution', () => {
})
})

describe('verifyChallenge', () => {
test('returns true for matching challengeId', () => {
const memo = Attribution.encode({ challengeId, serverId: 'api.example.com' })
expect(Attribution.verifyChallenge(memo, challengeId)).toBe(true)
})

test('returns false for wrong challengeId', () => {
const memo = Attribution.encode({ challengeId, serverId: 'api.example.com' })
expect(Attribution.verifyChallenge(memo, 'QNLtjAvrKKR0VlEGSIowhULqcGlCDU4fjrP-O7js8XE')).toBe(
false,
)
})
})

describe('decode', () => {
test('decodes an encoded memo with serverId and clientId', () => {
const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' })
Expand All @@ -162,10 +179,13 @@ describe('Attribution', () => {
expect(Attribution.decode(arbitrary)).toBeNull()
})

test('different encodes produce different nonces', () => {
const a = Attribution.decode(Attribution.encode({ serverId: 'api.example.com' }))
const b = Attribution.decode(Attribution.encode({ serverId: 'api.example.com' }))
expect(a!.nonce).not.toBe(b!.nonce)
test('decodes the challenge-bound suffix into the trailing field', () => {
const memo = Attribution.encode({ challengeId, serverId: 'api.example.com' })
const decoded = Attribution.decode(memo)
expect(decoded).not.toBeNull()
expect(decoded!.nonce.toLowerCase()).toBe(
Attribution.challengeSuffix(challengeId).toLowerCase(),
)
})

test('serverId fingerprint matches expected keccak hash', () => {
Expand Down
Loading
Loading