diff --git a/.changeset/quiet-queries-verify.md b/.changeset/quiet-queries-verify.md new file mode 100644 index 0000000..4fc986e --- /dev/null +++ b/.changeset/quiet-queries-verify.md @@ -0,0 +1,6 @@ +--- +'@inflowpayai/tap-seller': patch +--- + +Preserve original query spelling during TAP signature verification, rejecting changes between literal and +percent-encoded characters. diff --git a/conformance/inflow-specs.lock.json b/conformance/inflow-specs.lock.json index f2f88d7..107576d 100644 --- a/conformance/inflow-specs.lock.json +++ b/conformance/inflow-specs.lock.json @@ -1,4 +1,4 @@ { "repository": "inflowpayai/inflow-specs", - "revision": "5edce02da5f612c20f1bbed71b8e406442ecdbda" + "revision": "46f65400aa6e5b7a8f719a8378d5f36186e5c94d" } diff --git a/packages/tap-seller/README.md b/packages/tap-seller/README.md index 77c374b..69d8fb4 100644 --- a/packages/tap-seller/README.md +++ b/packages/tap-seller/README.md @@ -20,6 +20,10 @@ empty byte array represents a request with a body and therefore requires signed fields. Frameworks that expose only a relative request target must reconstruct the absolute URL from trusted request metadata before verification. +Pass the original absolute URL string to preserve the query exactly as received. A `URL` object is also accepted, but +constructing one can change query spelling, such as converting an apostrophe to `%27`; the verifier cannot recover the +original spelling from that object. Fragments are not part of the signed HTTP query. + ```ts import { createTapVerifier } from '@inflowpayai/tap-seller'; @@ -37,8 +41,9 @@ if (facts.intent === 'pay') { ``` Successful verification returns the signing key identifier, Ed25519 algorithm, `browse` or `pay` intent, nonce, creation -and expiration times, and the covered HTTP components. It establishes that a Visa-recognized agent key signed the -supplied request. It does not identify the buyer, authorize application access, or prove payment. +and expiration times, and the covered HTTP components. It establishes that a key trusted by the configured resolver +signed the supplied request; Visa is the default key source. It does not identify the buyer, authorize application +access, or prove payment. Use `createTapMiddleware` when a function wrapper fits the application: diff --git a/packages/tap-seller/src/verifier.ts b/packages/tap-seller/src/verifier.ts index c67d150..b55a040 100644 --- a/packages/tap-seller/src/verifier.ts +++ b/packages/tap-seller/src/verifier.ts @@ -150,11 +150,16 @@ function validateTime(input: ParsedInput, now: number): void { } function componentValues(request: TapRequest, url: URL): Map { + // RFC 9421 signs the original query; URL serialization can percent-encode it. + const target = String(request.url); + const fragmentStart = target.indexOf('#'); + const withoutFragment = fragmentStart < 0 ? target : target.slice(0, fragmentStart); + const queryStart = withoutFragment.indexOf('?'); const values = new Map([ ['@method', request.method], ['@authority', url.host], ['@path', url.pathname], - ['@query', url.search === '' ? '?' : url.search], + ['@query', queryStart < 0 ? '?' : withoutFragment.slice(queryStart)], ]); const digest = optionalHeader(request.headers, 'content-digest'); const contentType = diff --git a/packages/tap-seller/test/unit/verifier.test.ts b/packages/tap-seller/test/unit/verifier.test.ts index 8e49231..e75eb78 100644 --- a/packages/tap-seller/test/unit/verifier.test.ts +++ b/packages/tap-seller/test/unit/verifier.test.ts @@ -72,6 +72,49 @@ const resolver: TapKeyResolver = { }; describe('createTapVerifier', () => { + it.each([ + ["?q=O'Reilly", "?q=O'Reilly", false], + ['?q=O%27Reilly', '?q=O%27Reilly', false], + ['?q=%23%3F&kind=a&kind=b', '?q=%23%3F&kind=a&kind=b', false], + ['?', '', false], + ['?', '?', false], + ['?', '#fragment?not-a-query', false], + ["?q=O'Reilly", "?q=O'Reilly#fragment?not-a-query", false], + ['?q=O%27Reilly', "?q=O'Reilly", true], + ['?', '', true], + ] as const)('preserves signed query %s with input %s (URL object: %s)', async (query, suffix, urlObject) => { + const vector = required(vectors.positive[0]); + const sample = { + ...vector, + request: { ...vector.request, query }, + signatureBase: vector.signatureBase.replace(/^"@query": .*$/m, `"@query": ${query}`), + }; + const originPath = `https://${vector.request.authority}${vector.request.path}`; + const suppliedUrl = originPath + suffix; + const request = { + ...signedParameters(sample, vector.signatureInput.slice(5)), + url: urlObject ? new URL(suppliedUrl) : suppliedUrl, + }; + const originalUrl = String(request.url); + const next = vi.fn().mockReturnValue('recognized'); + const verify = createTapMiddleware( + createTapVerifier({ + keyResolver: resolver, + clock: () => vector.signatureParameters.created * 1000, + }), + ); + const tamperedQuery = + query === "?q=O'Reilly" ? '?q=O%27Reilly' : query === '?q=O%27Reilly' ? "?q=O'Reilly" : '?q=changed'; + await expect(verify({ ...request, url: originPath + tamperedQuery }, next)).rejects.toMatchObject({ + code: 'SIGNATURE_INVALID', + }); + expect(next).not.toHaveBeenCalled(); + await expect(verify(request, next)).resolves.toBe('recognized'); + await expect(verify(request, next)).rejects.toMatchObject({ code: 'NONCE_REPLAYED' }); + expect(next).toHaveBeenCalledOnce(); + expect(String(request.url)).toBe(originalUrl); + }); + 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 });