Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/tap-signature-parameters.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,15 @@ 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:
ADAPTER_NODE: ${{ steps.adapter.outputs.node }}
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
Expand All @@ -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
Expand Down
29 changes: 24 additions & 5 deletions conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion conformance/inflow-specs.lock.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"repository": "inflowpayai/inflow-specs",
"revision": "54689b7c93c07f259ed493897637fa33a7cfade2"
"revision": "5edce02da5f612c20f1bbed71b8e406442ecdbda"
}
104 changes: 104 additions & 0 deletions conformance/tap-shared-adapter.mjs
Original file line number Diff line number Diff line change
@@ -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`);
}
5 changes: 5 additions & 0 deletions docs/tap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 15 additions & 11 deletions packages/tap-seller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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

Expand Down
93 changes: 63 additions & 30 deletions packages/tap-seller/src/verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=\((?<components>(?:"[a-z@-]+" ?)+)\);created=(?<created>\d+);expires=(?<expires>\d+);keyid="(?<keyid>[A-Za-z0-9._~-]{1,128})";alg="(?<algorithm>[A-Za-z0-9-]+)";nonce="(?<nonce>[A-Za-z0-9+/_=-]+)";tag="(?<tag>agent-browser-auth|agent-payer-auth)"$/;
const SIGNATURE_PATTERN = /^sig2=:(?<value>[A-Za-z0-9+/]+={0,2}):$/;
const INPUT_PATTERN = /^ *sig2=\( *(?<components>"[a-z@-]+"(?: +"[a-z@-]+")*) *\)(?<parameters>[^\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=:(?<value>(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=?)?):[ \t]*$/;

interface ParsedInput {
readonly components: readonly string[];
Expand Down Expand Up @@ -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))) {
Expand All @@ -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<string, string | number | undefined>();
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}`,
};
}

Expand Down Expand Up @@ -157,13 +200,3 @@ function optionalHeader(headers: TapRequest['headers'], name: string): string |
function failure(code: ConstructorParameters<typeof TapVerificationError>[0], message: string): TapVerificationError {
return new TapVerificationError(code, message);
}

function isString(value: string | undefined): value is string {
return value !== undefined;
}

function requiredGroup(groups: Record<string, string | undefined>, name: string): string {
const value = groups[name];
if (value === undefined) throw failure('SIGNATURE_INPUT_INVALID', 'The TAP Signature-Input field is invalid.');
return value;
}
Loading
Loading