From f78e0a6cd241a32602830c4d6d88ebd989b22bdc Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Sat, 3 Oct 2026 17:55:16 -0700 Subject: [PATCH] fix(tap-seller): canonicalize signature parameters --- .changeset/tap-signature-parameters.md | 8 + .github/workflows/conformance.yml | 6 +- conformance/README.md | 29 +- conformance/inflow-specs.lock.json | 2 +- conformance/tap-shared-adapter.mjs | 104 +++++++ docs/tap/README.md | 5 + package.json | 1 + packages/tap-seller/README.md | 26 +- packages/tap-seller/src/verifier.ts | 93 ++++-- .../tap-seller/test/unit/verifier.test.ts | 283 +++++++++++++++++- scripts/conformance.mjs | 46 +-- scripts/conformance.test.mjs | 7 + 12 files changed, 540 insertions(+), 70 deletions(-) create mode 100644 .changeset/tap-signature-parameters.md create mode 100644 conformance/tap-shared-adapter.mjs diff --git a/.changeset/tap-signature-parameters.md b/.changeset/tap-signature-parameters.md new file mode 100644 index 0000000..8e432b1 --- /dev/null +++ b/.changeset/tap-signature-parameters.md @@ -0,0 +1,8 @@ +--- +'@inflowpayai/tap-seller': patch +--- + +Accept valid TAP signature parameter ordering and Structured Field serialization. Repeated parameters use their last +value consistently for validation and signature verification. + +Reject non-Ed25519 key material returned by a custom key resolver. diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index ab6b143..5a3ca0d 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -85,7 +85,7 @@ jobs: id: build run: | pnpm --filter @inflowpayai/mpp-buyer... --filter @inflowpayai/mpp-seller... \ - --filter @inflowpayai/x402-buyer... --filter @inflowpayai/x402-seller... build + --filter @inflowpayai/x402-buyer... --filter @inflowpayai/x402-seller... --filter @inflowpayai/tap-seller build - name: Pinned contract env: @@ -93,7 +93,7 @@ jobs: run: | mkdir -p "$RUNNER_TEMP/conformance" result=0 - for suite in runtime mpp x402; do + for suite in runtime mpp x402 tap; do node scripts/conformance.mjs --suite "$suite" --adapter-node "$ADAPTER_NODE" \ --contract-root ../contract-pinned \ --output "$RUNNER_TEMP/conformance/pinned-$suite.json" || result=1 @@ -108,7 +108,7 @@ jobs: mkdir -p "$RUNNER_TEMP/conformance" revision=$(git -C ../contract-current rev-parse HEAD) result=0 - for suite in runtime mpp x402; do + for suite in runtime mpp x402 tap; do node scripts/conformance.mjs --suite "$suite" --adapter-node "$ADAPTER_NODE" \ --contract-root ../contract-current --contract-revision "$revision" \ --output "$RUNNER_TEMP/conformance/current-$suite.json" || result=1 diff --git a/conformance/README.md b/conformance/README.md index 87120dc..b4ca6d0 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -86,17 +86,36 @@ The report records all three SDK package versions and their installed `@x402/cor These synthetic platform responses do not certify live signing, settlement, external-wallet execution, foundation middleware, or sponsorship execution. +## TAP Seller + +```sh +pnpm tap:conformance:shared --contract-root ../inflow-specs --output /tmp/inflow-tap-report.json +``` + +The adapter passes synthetic signed requests through the public `createTapVerifier` and `createTapMiddleware` functions. +The SDK parses signature fields, reconstructs the signed message, verifies Ed25519 signatures and body digests, and +claims nonces through its replay store. The adapter records protected-handler calls and replay claims; it does not +implement signature parsing or verification. Key-service cases use the public `VisaTapKeyResolver` against the runner's +local HTTP server, including cache refresh, outage fallback and concurrent retrieval. + +The report includes valid parameter orders and duplicate-parameter handling, request tampering, time boundaries, replay, +custom resolver/store failures and caller-input preservation. It records the TAP package version, with no upstream +runtime dependencies. This is local cryptographic and HTTP integration coverage, not certification of production Visa +key registration or distributed replay storage. `pnpm tap:conformance` checks the separate signing fixtures; the shared +command tests the built SDK itself. + ## Hosted reports and contract drift The **shared conformance** workflow runs on pull requests, pushes to `main`, and manual dispatch. Each Node 22/24 and -locked/latest foundation combination runs all three suites against both the pinned contract and the current -`inflow-specs` main commit. A failure in one suite does not prevent the other suites from producing reports; any failure -still fails the job. The current-contract step runs even if the pinned cases fail. +locked/latest foundation combination runs runtime, MPP, x402 and TAP suites against both the pinned contract and the +current `inflow-specs` main commit. A failure in one suite does not prevent the other suites from producing reports; any +failure still fails the job. The current-contract step runs even if the pinned cases fail. Open the workflow run's **Artifacts** section and download `conformance-node22-locked`, `conformance-node22-latest`, `conformance-node24-locked`, or `conformance-node24-latest`. Each artifact contains `pinned-*.json` and `current-*.json` -reports for runtime, MPP, and x402, retained for 14 days. Failed runs also upload available reports. Check `completed` -and `passed`; an empty or incomplete report is not passing evidence. Installation/build failures may prevent reports. +reports for runtime, MPP, x402 and TAP, retained for 14 days. Failed runs also upload available reports. Check +`completed` and `passed`; an empty or incomplete report is not passing evidence. Installation/build failures may prevent +reports. The contract runner uses Node 24; `--adapter-node` selects the executable that runs the SDK adapter. Reported `implementation.runtime` is that adapter's actual Node version. The latest-dependency jobs intentionally modify diff --git a/conformance/inflow-specs.lock.json b/conformance/inflow-specs.lock.json index e5d9c27..f2f88d7 100644 --- a/conformance/inflow-specs.lock.json +++ b/conformance/inflow-specs.lock.json @@ -1,4 +1,4 @@ { "repository": "inflowpayai/inflow-specs", - "revision": "54689b7c93c07f259ed493897637fa33a7cfade2" + "revision": "5edce02da5f612c20f1bbed71b8e406442ecdbda" } diff --git a/conformance/tap-shared-adapter.mjs b/conformance/tap-shared-adapter.mjs new file mode 100644 index 0000000..0c9c04a --- /dev/null +++ b/conformance/tap-shared-adapter.mjs @@ -0,0 +1,104 @@ +import { createPublicKey } from 'node:crypto'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; +import { isDeepStrictEqual } from 'node:util'; +import { + createTapVerifier, + createTapMiddleware, + MemoryTapReplayStore, + VisaTapKeyResolver, + TapVerificationError, +} from '../packages/tap-seller/dist/index.js'; + +export async function execute(operation, input) { + if (operation !== 'tap.seller.verify') throw new Error(`Unsupported operation: ${operation}`); + let now = 0; + const clock = () => now; + const resolverFailure = new Error('Synthetic resolver failure'); + const storeFailure = new Error('Synthetic replay-store failure'); + const key = createPublicKey({ key: input.key, format: 'jwk' }); + const keyResolver = + input.resolver === 'http' + ? new VisaTapKeyResolver({ + url: new URL('/keys', input.base_url), + clock, + cacheTtlMs: input.cache_ttl_ms, + cacheMaxAgeMs: input.cache_max_age_ms, + }) + : { + async resolve(keyid, algorithm) { + if (input.resolver_failure) throw resolverFailure; + if (input.resolver_completion_ms !== undefined) now = input.resolver_completion_ms; + return keyid === input.key.kid && algorithm === 'ed25519' ? { keyid, algorithm, key } : undefined; + }, + }; + const memory = new MemoryTapReplayStore(clock); + let claim_calls = 0; + let handler_calls = 0; + const middleware = createTapMiddleware( + createTapVerifier({ + clock, + keyResolver, + replayStore: { + claim(keyid, nonce, expires) { + claim_calls++; + if (input.store_failure) throw storeFailure; + return memory.claim(keyid, nonce, expires); + }, + }, + }), + ); + const steps = []; + for (const step of input.steps) { + now = step.now_ms; + const accepted = []; + const rejected = []; + await Promise.all( + step.requests.map(async (request) => { + const supplied = { + method: request.method, + url: request.url, + headers: request.headers, + ...(request.body_base64 === undefined + ? {} + : { body: Uint8Array.from(Buffer.from(request.body_base64, 'base64')) }), + }; + const before = structuredClone(supplied); + try { + await middleware(supplied, (facts) => { + handler_calls++; + accepted.push(facts); + }); + } catch (error) { + if (error instanceof TapVerificationError) rejected.push(error.code); + else if (error === resolverFailure) rejected.push('CUSTOM_RESOLVER_FAILED'); + else if (error === storeFailure) rejected.push('CUSTOM_STORE_FAILED'); + else throw error; + } finally { + if (!isDeepStrictEqual(before, supplied)) throw new Error('Caller request was mutated'); + } + }), + ); + rejected.sort(); + steps.push({ accepted, rejected }); + } + return { steps, handler_calls, claim_calls }; +} + +export async function respond(request) { + const envelope = { adapter_version: '1', sequence: request.sequence, case_id: request.case_id }; + try { + if (request.adapter_version !== '1') throw new Error('Unsupported adapter version'); + const before = structuredClone(request.input); + const result = await execute(request.operation, request.input); + if (!isDeepStrictEqual(before, request.input)) throw new Error('Caller input was mutated'); + return { ...envelope, result }; + } catch (error) { + return { ...envelope, error: { code: 'ADAPTER_ERROR', message: error.message } }; + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + for await (const line of createInterface({ input: process.stdin, crlfDelay: Infinity })) + process.stdout.write(`${JSON.stringify(await respond(JSON.parse(line)))}\n`); +} diff --git a/docs/tap/README.md b/docs/tap/README.md index 1c6edb0..e70b7df 100644 --- a/docs/tap/README.md +++ b/docs/tap/README.md @@ -17,6 +17,11 @@ storage may finish after expiration. Visa's prose examples use `alg="Ed25519"`, spelling and the RFC-registered lowercase spelling, preserves the received value in the signature base, and rejects every other algorithm value. +Signature parameters may appear in any order. Repeated parameter names use the last value while keeping their first +position, following RFC 8941. The verifier applies that same parsed value to time/key checks and signature +reconstruction; it does not verify the raw parameter substring. Duplicate covered components remain invalid. This rule +does not select the last value of an arbitrary repeated HTTP header. + The `agent-browser-auth` tag identifies discovery and enrollment inspection. The `agent-payer-auth` tag identifies MPP and x402 payment attempts. These tags describe the agent interaction; payment and application authorization remain independent checks. diff --git a/package.json b/package.json index f8059f8..22772aa 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "mpp:conformance": "node scripts/mpp-conformance.mjs", "mpp:conformance:update": "node scripts/mpp-conformance.mjs --update", "tap:conformance": "node scripts/tap-conformance.mjs", + "tap:conformance:shared": "pnpm --filter @inflowpayai/tap-seller build && node scripts/conformance.mjs --suite tap", "check-exports": "node scripts/check-exports.mjs", "verify-publish": "node scripts/verify-publish.mjs", "check-publish": "pnpm build && pnpm check-exports && pnpm verify-publish", diff --git a/packages/tap-seller/README.md b/packages/tap-seller/README.md index d2354a0..77c374b 100644 --- a/packages/tap-seller/README.md +++ b/packages/tap-seller/README.md @@ -62,6 +62,10 @@ const response = await verifyTap( may supply a `VisaTapKeyResolver` with custom fetch, timeout, and cache settings, or a different `TapKeyResolver` that implements the same `keyid` contract. Keys absent from the configured resolver fail closed with `KEY_NOT_FOUND`. +Signature parameters follow Structured Fields: their order is preserved, and a repeated parameter uses its last value +for both validation and signature reconstruction. Repeating a covered component is invalid. Custom resolvers must return +trusted Ed25519 key material for the requested identifier. + ## Replay and failure handling `MemoryTapReplayStore` is process-local. Multi-process deployments must provide a `TapReplayStore` whose `claim` @@ -72,17 +76,17 @@ keys, invalid signatures and body digests, invalid lifetimes, expired or not-yet Reject the merchant request on every verification error. A cached key may be used during a temporary key-service outage for at most the resolver's configured maximum cache age; an unavailable uncached key fails closed. -| Code | Meaning | -| ---------------------------- | ------------------------------------------------------------- | -| `SIGNATURE_INPUT_INVALID` | Required signature input is absent, duplicated, or malformed. | -| `SIGNATURE_INVALID` | The cryptographic signature does not verify. | -| `CONTENT_DIGEST_INVALID` | The supplied body bytes do not match the signed digest. | -| `SIGNATURE_LIFETIME_INVALID` | The declared validity interval is invalid. | -| `SIGNATURE_NOT_YET_VALID` | The request was received before its validity interval. | -| `SIGNATURE_EXPIRED` | The request was received at or after its expiration time. | -| `KEY_NOT_FOUND` | The configured resolver has no matching verification key. | -| `KEY_RETRIEVAL_FAILED` | No usable cached key exists and key retrieval failed. | -| `NONCE_REPLAYED` | The signing key and nonce combination was already claimed. | +| Code | Meaning | +| ---------------------------- | ------------------------------------------------------------------------------ | +| `SIGNATURE_INPUT_INVALID` | Signature fields or covered components are missing, malformed, or unsupported. | +| `SIGNATURE_INVALID` | The cryptographic signature does not verify. | +| `CONTENT_DIGEST_INVALID` | The supplied body bytes do not match the signed digest. | +| `SIGNATURE_LIFETIME_INVALID` | The declared validity interval is invalid. | +| `SIGNATURE_NOT_YET_VALID` | The request was received before its validity interval. | +| `SIGNATURE_EXPIRED` | The request was received at or after its expiration time. | +| `KEY_NOT_FOUND` | The configured resolver has no matching verification key. | +| `KEY_RETRIEVAL_FAILED` | No usable cached key exists and key retrieval failed. | +| `NONCE_REPLAYED` | The signing key and nonce combination was already claimed. | ## Request investigation diff --git a/packages/tap-seller/src/verifier.ts b/packages/tap-seller/src/verifier.ts index cb8e284..c67d150 100644 --- a/packages/tap-seller/src/verifier.ts +++ b/packages/tap-seller/src/verifier.ts @@ -6,9 +6,11 @@ import type { TapRequest, TapVerificationFacts, TapVerifier, TapVerifierOptions const REQUIRED_COMPONENTS = ['@method', '@authority', '@path', '@query'] as const; const BODY_COMPONENTS = ['content-digest', 'content-type'] as const; -const INPUT_PATTERN = - /^sig2=\((?(?:"[a-z@-]+" ?)+)\);created=(?\d+);expires=(?\d+);keyid="(?[A-Za-z0-9._~-]{1,128})";alg="(?[A-Za-z0-9-]+)";nonce="(?[A-Za-z0-9+/_=-]+)";tag="(?agent-browser-auth|agent-payer-auth)"$/; -const SIGNATURE_PATTERN = /^sig2=:(?[A-Za-z0-9+/]+={0,2}):$/; +const INPUT_PATTERN = /^ *sig2=\( *(?"[a-z@-]+"(?: +"[a-z@-]+")*) *\)(?[^\r\n]*)$/; +const PARAMETER_PATTERN = + /^; *(created|expires|keyid|alg|nonce|tag)(?:=("(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\["\\])*"|-?\d{1,12}\.\d{1,3}|-?\d{1,15}|\?[01]|:(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=?)?:|[A-Za-z*][A-Za-z0-9!#$%&'*+.^_`|~:/-]*))?(?=;|[ \t]*$)/; +const SIGNATURE_PATTERN = + /^ *sig2=:(?(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=?)?):[ \t]*$/; interface ParsedInput { readonly components: readonly string[]; @@ -45,7 +47,10 @@ export function createTapVerifier(options: TapVerifierOptions = {}): TapVerifier ...parsed.components.map((component) => `"${component}": ${values.get(component) ?? ''}`), `"@signature-params": ${parsed.parameters}`, ].join('\n'); - if (!verify(null, Buffer.from(signatureBase), key.key, parseSignature(signature))) { + if ( + key.key.asymmetricKeyType !== 'ed25519' || + !verify(null, Buffer.from(signatureBase), key.key, parseSignature(signature)) + ) { throw failure('SIGNATURE_INVALID', 'The TAP signature is invalid.'); } if (!(await replayStore.claim(parsed.keyid, parsed.nonce, parsed.expires))) { @@ -66,28 +71,66 @@ export function createTapVerifier(options: TapVerifierOptions = {}): TapVerifier } function parseInput(value: string): ParsedInput { - const match = INPUT_PATTERN.exec(value); - const groups = match?.groups; - if (groups === undefined) throw failure('SIGNATURE_INPUT_INVALID', 'The TAP Signature-Input field is invalid.'); - const componentsValue = requiredGroup(groups, 'components'); - const components = [...componentsValue.matchAll(/"([a-z@-]+)"/g)].map((component) => component[1]).filter(isString); - if (components.length === 0 || new Set(components).size !== components.length) { + const groups = INPUT_PATTERN.exec(value)?.groups; + const componentsValue = groups?.['components']; + const parameterValue = groups?.['parameters']; + if (componentsValue === undefined || parameterValue === undefined) { + throw failure('SIGNATURE_INPUT_INVALID', 'The TAP Signature-Input field is invalid.'); + } + const components = componentsValue.split(/ +/).map((component) => component.slice(1, -1)); + if (new Set(components).size !== components.length) { throw failure('SIGNATURE_INPUT_INVALID', 'The TAP covered components are invalid.'); } - const algorithm = requiredGroup(groups, 'algorithm'); - if (!SUPPORTED_ALGORITHMS.has(algorithm)) { - throw failure('SIGNATURE_INPUT_INVALID', 'The TAP signature algorithm is invalid.'); + const parameters = new Map(); + let remaining = parameterValue.replace(/[ \t]+$/, ''); + while (remaining !== '') { + const parameter = PARAMETER_PATTERN.exec(remaining); + const name = parameter?.[1]; + if (parameter === null || name === undefined) { + throw failure('SIGNATURE_INPUT_INVALID', 'The TAP Signature-Input field is invalid.'); + } + const encoded = parameter[2]; + const decoded = + encoded?.startsWith('"') === true + ? encoded.slice(1, -1).replace(/\\(["\\])/g, '$1') + : encoded !== undefined && /^-?\d+$/.test(encoded) + ? Number(encoded) + : undefined; + // RFC 8941 parameters keep their first position and their last value, including its type. + parameters.set(name, decoded); + remaining = remaining.slice(parameter[0].length); + } + const created = parameters.get('created'); + const expires = parameters.get('expires'); + const keyid = parameters.get('keyid'); + const algorithm = parameters.get('alg'); + const nonce = parameters.get('nonce'); + const tag = parameters.get('tag'); + if ( + typeof created !== 'number' || + typeof expires !== 'number' || + typeof keyid !== 'string' || + keyid === '' || + typeof algorithm !== 'string' || + !SUPPORTED_ALGORITHMS.has(algorithm) || + typeof nonce !== 'string' || + nonce === '' || + (tag !== 'agent-browser-auth' && tag !== 'agent-payer-auth') + ) { + throw failure('SIGNATURE_INPUT_INVALID', 'The TAP signature parameters are invalid.'); } - const parameters = value.slice('sig2='.length); + const serialized = [...parameters] + .map(([name, item]) => `;${name}=${typeof item === 'string' ? `"${item.replace(/["\\]/g, '\\$&')}"` : item}`) + .join(''); return { components, - created: Number(requiredGroup(groups, 'created')), - expires: Number(requiredGroup(groups, 'expires')), - keyid: requiredGroup(groups, 'keyid'), + created, + expires, + keyid, algorithm: 'ed25519', - nonce: requiredGroup(groups, 'nonce'), - tag: requiredGroup(groups, 'tag') as ParsedInput['tag'], - parameters, + nonce, + tag, + parameters: `(${components.map((component) => `"${component}"`).join(' ')})${serialized}`, }; } @@ -157,13 +200,3 @@ function optionalHeader(headers: TapRequest['headers'], name: string): string | function failure(code: ConstructorParameters[0], message: string): TapVerificationError { return new TapVerificationError(code, message); } - -function isString(value: string | undefined): value is string { - return value !== undefined; -} - -function requiredGroup(groups: Record, name: string): string { - const value = groups[name]; - if (value === undefined) throw failure('SIGNATURE_INPUT_INVALID', 'The TAP Signature-Input field is invalid.'); - return value; -} diff --git a/packages/tap-seller/test/unit/verifier.test.ts b/packages/tap-seller/test/unit/verifier.test.ts index 676f218..8e49231 100644 --- a/packages/tap-seller/test/unit/verifier.test.ts +++ b/packages/tap-seller/test/unit/verifier.test.ts @@ -1,4 +1,4 @@ -import { createPrivateKey, createPublicKey, sign } from 'node:crypto'; +import { createPrivateKey, createPublicKey, generateKeyPairSync, sign } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { describe, expect, it, vi } from 'vitest'; import { @@ -72,6 +72,263 @@ const resolver: TapKeyResolver = { }; describe('createTapVerifier', () => { + it('rejects RSA key material even when a custom resolver labels it ed25519', async () => { + const vector = required(vectors.positive[0]); + const { privateKey, publicKey: rsaKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const claim = vi.fn().mockReturnValue(true); + const next = vi.fn(); + const verifier = createTapVerifier({ + clock: () => vector.signatureParameters.created * 1000, + keyResolver: { + resolve: vi.fn().mockResolvedValue({ keyid: vectors.testKey.keyid, algorithm: 'ed25519', key: rsaKey }), + }, + replayStore: { claim }, + }); + await expect( + createTapMiddleware(verifier)( + { + ...toRequest(vector), + headers: { + ...toRequest(vector).headers, + signature: `sig2=:${sign(null, Buffer.from(vector.signatureBase), privateKey).toString('base64')}:`, + }, + }, + next, + ), + ).rejects.toMatchObject({ code: 'SIGNATURE_INVALID' }); + expect(claim).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('uses the default resolver and clock through the public verifier', async () => { + const vector = required(vectors.positive[0]); + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + keys: [ + { + ...publicKey.export({ format: 'jwk' }), + kid: vectors.testKey.keyid, + alg: 'Ed25519', + }, + ], + }), + ), + ); + const clock = vi.spyOn(Date, 'now').mockReturnValue(vector.signatureParameters.created * 1000); + try { + await expect(createTapVerifier().verify(toRequest(vector))).resolves.toMatchObject({ verified: true }); + expect(fetch).toHaveBeenCalledOnce(); + } finally { + fetch.mockRestore(); + clock.mockRestore(); + } + }); + + it.each([ + 'order', + 'whitespace', + 'leading-zero', + 'created', + 'expires', + 'keyid', + 'alg', + 'nonce', + 'tag', + 'string-type', + 'decimal-type', + 'token-type', + 'bytes-type', + 'boolean-type', + 'implicit-boolean', + ])('verifies canonical signature parameters with %s input', async (shape) => { + const vector = required(vectors.positive[0]); + let canonical = vector.signatureInput.slice(5); + let supplied = canonical; + if (shape === 'order') { + canonical = canonical.replace(/;created=(\d+);expires=(\d+)/, ';expires=$2;created=$1'); + supplied = canonical; + } else if (shape === 'whitespace') { + supplied = canonical.replaceAll(' ', ' ').replaceAll(';', '; ').replace('(', '( ').replace(')', ' )'); + } else if (shape === 'leading-zero') { + supplied = canonical.replace(';created=', ';created=000'); + } else { + const prefix: Record = { + created: `created=${vector.signatureParameters.created + 1000}`, + expires: 'expires=1', + keyid: 'keyid="untrusted"', + alg: 'alg="rsa"', + nonce: 'nonce="other"', + tag: 'tag="agent-payer-auth"', + 'string-type': 'created="ignored"', + 'decimal-type': 'created=1.5', + 'token-type': 'created=ignored', + 'bytes-type': 'created=:AQI=:', + 'boolean-type': 'created=?0', + 'implicit-boolean': 'created', + }; + const field = required(prefix[shape]); + const name = required(field.split('=')[0]); + supplied = canonical.replace(`;${name}=`, `;${field};${name}=`); + } + const request = signedParameters(vector, canonical, `sig2=${supplied}`); + const verifier = createTapVerifier({ + keyResolver: resolver, + clock: () => vector.signatureParameters.created * 1000, + }); + await expect(createTapMiddleware(verifier)(request, (facts) => facts)).resolves.toMatchObject({ + verified: true, + created: vector.signatureParameters.created, + expires: vector.signatureParameters.expires, + keyid: vectors.testKey.keyid, + intent: 'browse', + }); + }); + + it('unescapes key identifiers and nonces consistently for lookup, signing and replay', async () => { + const vector = required(vectors.positive[0]); + const keyid = 'key\\"with escapes'; + const nonce = 'nonce\\"with escapes'; + const canonical = vector.signatureInput + .slice(5) + .replace(/keyid="[^"]*"/, `keyid=${JSON.stringify(keyid)}`) + .replace(/nonce="[^"]*"/, `nonce=${JSON.stringify(nonce)}`); + const resolve = vi.fn().mockResolvedValue({ keyid, algorithm: 'ed25519', key: publicKey }); + const claim = vi.fn().mockReturnValue(true); + const verifier = createTapVerifier({ + keyResolver: { resolve }, + replayStore: { claim }, + clock: () => vector.signatureParameters.created * 1000, + }); + await expect(verifier.verify(signedParameters(vector, canonical))).resolves.toMatchObject({ keyid, nonce }); + expect(resolve).toHaveBeenCalledWith(keyid, 'ed25519'); + expect(claim).toHaveBeenCalledWith(keyid, nonce, vector.signatureParameters.expires); + }); + + it('does not verify a signature over raw duplicate parameters instead of their canonical form', async () => { + const vector = required(vectors.positive[0]); + const raw = vector.signatureInput.slice(5) + `;created=${vector.signatureParameters.created}`; + await expect( + createTapVerifier({ keyResolver: resolver, clock: () => vector.signatureParameters.created * 1000 }).verify( + signedParameters(vector, raw), + ), + ).rejects.toMatchObject({ code: 'SIGNATURE_INVALID' }); + }); + + it('accepts outer signature whitespace and omitted base64 padding', async () => { + const vector = required(vectors.positive[0]); + const signature = ` ${vector.signature.replace(/=+:$/, ':')} \t`; + await expect( + createTapVerifier({ keyResolver: resolver, clock: () => vector.signatureParameters.created * 1000 }).verify({ + ...toRequest(vector), + headers: { ...toRequest(vector).headers, signature }, + }), + ).resolves.toMatchObject({ verified: true }); + }); + + it('rejects a malformed overwritten byte sequence', async () => { + const vector = required(vectors.positive[0]); + const input = vector.signatureInput.replace(';created=', ';created=:A:;created='); + await expect( + createTapVerifier({ keyResolver: resolver, clock: () => vector.signatureParameters.created * 1000 }).verify({ + ...toRequest(vector), + headers: { ...toRequest(vector).headers, 'signature-input': input }, + }), + ).rejects.toMatchObject({ code: 'SIGNATURE_INPUT_INVALID' }); + }); + + it.each([ + [';created=1', 'SIGNATURE_LIFETIME_INVALID'], + [';expires=1', 'SIGNATURE_LIFETIME_INVALID'], + [';keyid="untrusted"', 'KEY_NOT_FOUND'], + [';alg="rsa"', 'SIGNATURE_INPUT_INVALID'], + [';nonce="other"', 'SIGNATURE_INVALID'], + [';tag="agent-payer-auth"', 'SIGNATURE_INVALID'], + ])('rejects an unsigned effective parameter change %s', async (suffix, code) => { + const vector = required(vectors.positive[0]); + const claim = vi.fn().mockReturnValue(true); + const next = vi.fn(); + const verifier = createTapVerifier({ + keyResolver: resolver, + replayStore: { claim }, + clock: () => vector.signatureParameters.created * 1000, + }); + await expect( + createTapMiddleware(verifier)( + { + ...toRequest(vector), + headers: { + ...toRequest(vector).headers, + 'signature-input': vector.signatureInput + suffix, + }, + }, + next, + ), + ).rejects.toMatchObject({ code }); + expect(claim).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it.each([ + ';created="1"', + ';created=1.0', + ';created=?1', + ';created', + ';created=:AQI=:', + ';created=token', + ';created=1000000000000000', + ';created=1e3', + ';created=+1', + ';created=1.0000', + ';expires="1"', + ';keyid=1', + ';keyid=""', + ';nonce=1', + ';nonce=""', + ';alg=1', + ';tag=1', + ';keyid="bad\\escape"', + ';keyid="bad\u007f"', + ';unknown=1', + '; created =1', + ';\tcreated=1', + ';created=1 ;created=2', + ';created=1\n', + ';created=1\r', + ';created=1, sig3=()', + ])('rejects malformed or incorrectly typed parameters %s', async (suffix) => { + const vector = required(vectors.positive[0]); + const resolve = vi.fn(); + const verifier = createTapVerifier({ + keyResolver: { resolve }, + clock: () => vector.signatureParameters.created * 1000, + }); + await expect( + verifier.verify({ + ...toRequest(vector), + headers: { + ...toRequest(vector).headers, + 'signature-input': vector.signatureInput + suffix, + }, + }), + ).rejects.toMatchObject({ code: 'SIGNATURE_INPUT_INVALID' }); + expect(resolve).not.toHaveBeenCalled(); + }); + + it.each(['created', 'expires', 'keyid', 'alg', 'nonce', 'tag'])('requires the %s parameter', async (name) => { + const vector = required(vectors.positive[0]); + const input = vector.signatureInput.replace(new RegExp(`;${name}=(?:"[^"]*"|[0-9]+)`), ''); + await expect( + createTapVerifier({ keyResolver: resolver }).verify({ + ...toRequest(vector), + headers: { + ...toRequest(vector).headers, + 'signature-input': input, + }, + }), + ).rejects.toMatchObject({ code: 'SIGNATURE_INPUT_INVALID' }); + }); + it.each(['missing', 'undefined', 'empty-array', 'multiple-values', 'duplicate-case', 'headers'])( 'rejects %s content-type even with a signature over an empty field', async (shape) => { @@ -369,6 +626,10 @@ describe('createTapVerifier', () => { }); describe('VisaTapKeyResolver', () => { + it('treats an empty key-set object as no trusted keys', async () => { + const resolver = new VisaTapKeyResolver({ fetch: vi.fn().mockResolvedValue(new Response('{}')) }); + await expect(resolver.resolve('missing', 'ed25519')).resolves.toBeUndefined(); + }); it('retrieves an Ed25519 JWK and selects it by key identifier', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response( @@ -609,6 +870,26 @@ function required(value: T | undefined): T { return value; } +function signedParameters(vector: Vector, canonical: string, supplied = `sig2=${canonical}`): TapRequest { + const key = createPrivateKey({ + key: Buffer.concat([ + Buffer.from('302e020100300506032b657004220420', 'hex'), + Buffer.from(vectors.testKey.privateSeedHex, 'hex'), + ]), + format: 'der', + type: 'pkcs8', + }); + const base = vector.signatureBase.replace(/"@signature-params": .*/, `"@signature-params": ${canonical}`); + return { + ...toRequest(vector), + headers: { + ...toRequest(vector).headers, + 'signature-input': supplied, + signature: `sig2=:${sign(null, Buffer.from(base), key).toString('base64')}:`, + }, + }; +} + function mutateNegative(id: string, vector: Vector): { request: TapRequest; verificationTime: number } { const request = toRequest(vector); const headers = { ...(request.headers as Readonly>) }; diff --git a/scripts/conformance.mjs b/scripts/conformance.mjs index cf4b94c..a3c111c 100644 --- a/scripts/conformance.mjs +++ b/scripts/conformance.mjs @@ -11,30 +11,33 @@ export async function implementation(suite = 'runtime', adapterNode = process.ex const packages = {}; const dependencies = {}; const products = - suite === 'mpp' - ? [ - ['mpp', 'mppx'], - ['mpp-buyer', 'mppx'], - ['mpp-seller', 'mppx'], - ] - : suite === 'x402' + suite === 'tap' + ? [['tap-seller', undefined]] + : suite === 'mpp' ? [ - ['x402', '@x402/core'], - ['x402-buyer', '@x402/core'], - ['x402-seller', '@x402/core'], - ['x402-seller', '@x402/extensions'], - ] - : [ ['mpp', 'mppx'], - ['x402', '@x402/core'], - ]; + ['mpp-buyer', 'mppx'], + ['mpp-seller', 'mppx'], + ] + : suite === 'x402' + ? [ + ['x402', '@x402/core'], + ['x402-buyer', '@x402/core'], + ['x402-seller', '@x402/core'], + ['x402-seller', '@x402/extensions'], + ] + : [ + ['mpp', 'mppx'], + ['x402', '@x402/core'], + ]; for (const [product, dependency] of products) { const packageRoot = resolve(root, 'packages', product); const manifest = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf8')); + packages[manifest.name] = manifest.version; + if (dependency === undefined) continue; const installed = JSON.parse( await readFile(resolve(packageRoot, 'node_modules', dependency, 'package.json'), 'utf8'), ); - packages[manifest.name] = manifest.version; if (dependencies[installed.name] !== undefined && dependencies[installed.name] !== installed.version) { throw new Error(`Conflicting installed versions for ${installed.name}`); } @@ -64,10 +67,10 @@ async function main() { }); if (!values['contract-root'] || !values.output) { throw new Error( - 'Usage: node scripts/conformance.mjs --suite runtime|mpp|x402 --contract-root PATH --output NEW_REPORT.json', + 'Usage: node scripts/conformance.mjs --suite runtime|mpp|x402|tap --contract-root PATH --output NEW_REPORT.json', ); } - if (!['runtime', 'mpp', 'x402'].includes(values.suite)) throw new Error('Unknown conformance suite'); + if (!['runtime', 'mpp', 'x402', 'tap'].includes(values.suite)) throw new Error('Unknown conformance suite'); const contractRoot = resolve(values['contract-root']); const lock = JSON.parse(await readFile(new URL('../conformance/inflow-specs.lock.json', import.meta.url), 'utf8')); verifyContract(contractRoot, values['contract-revision'] ?? lock.revision); @@ -79,6 +82,7 @@ async function main() { runtime: ['runtime'], mpp: ['mpp-core', 'mpp-buyer', 'mpp-seller'], x402: ['x402-core', 'x402-buyer', 'x402-seller'], + tap: ['tap-seller'], }[values.suite]; const metadata = await implementation(values.suite, adapterNode); const output = await open(resolve(values.output), 'wx', 0o600); @@ -89,7 +93,11 @@ async function main() { try { const report = await run({ index, - capabilities: { suites, supported_features: [], unsupported_features: [] }, + capabilities: { + suites, + supported_features: values.suite === 'mpp' ? ['mpp-seller-subscriptions'] : [], + unsupported_features: [], + }, implementation: metadata, command: [ adapterNode, diff --git a/scripts/conformance.test.mjs b/scripts/conformance.test.mjs index 3809779..0398198 100644 --- a/scripts/conformance.test.mjs +++ b/scripts/conformance.test.mjs @@ -37,3 +37,10 @@ test('report runtime comes from the selected adapter executable', async () => { assert.equal((await implementation('runtime', process.execPath)).runtime, process.version); await assert.rejects(() => implementation('runtime', '/nonexistent/inflow-test-node'), /ENOENT/); }); + +test('TAP metadata reports the actual Seller package without upstream dependencies', async () => { + const metadata = await implementation('tap'); + assert.deepEqual(Object.keys(metadata.packages), ['@inflowpayai/tap-seller']); + assert.match(metadata.packages['@inflowpayai/tap-seller'], /^\d+\.\d+\.\d+/); + assert.deepEqual(metadata.dependencies, {}); +});