From f245c51adf8dd1749c4d241b78062504edc94907 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Mon, 24 Aug 2026 21:22:26 -0400 Subject: [PATCH 01/26] feat: add spec-compliant attestation issuance Expose Blind RSA token pooling through the SDK and CLI while enforcing trusted issuer discovery and verifying every unblinded signature. Co-authored-by: Cursor Committed-By-Agent: cursor --- CLAUDE.md | 13 +- README.md | 11 +- packages/cli/src/__tests__/cli.test.ts | 8 +- .../src/auth/__tests__/auth-resource.test.ts | 4 +- .../src/auth/__tests__/merge-access.test.ts | 4 +- packages/cli/src/auth/scopes.ts | 1 + packages/cli/src/cli.tsx | 2 + .../cli/src/commands/attestations/index.tsx | 29 ++ .../cli/src/commands/attestations/schema.ts | 20 + .../utils/__tests__/resource-factory.test.ts | 4 + packages/cli/src/utils/resource-factory.ts | 30 +- packages/sdk/src/client.ts | 4 + packages/sdk/src/index.ts | 1 + .../__tests__/attestations-crypto.test.ts | 92 ++++ .../resources/__tests__/attestations.test.ts | 133 ++++++ .../src/resources/__tests__/factory.test.ts | 3 + .../sdk/src/resources/attestations-crypto.ts | 395 ++++++++++++++++++ packages/sdk/src/resources/attestations.ts | 379 +++++++++++++++++ packages/sdk/src/resources/interfaces.ts | 16 + skills/create-payment-credential/SKILL.md | 1 + 20 files changed, 1141 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/commands/attestations/index.tsx create mode 100644 packages/cli/src/commands/attestations/schema.ts create mode 100644 packages/sdk/src/resources/__tests__/attestations-crypto.test.ts create mode 100644 packages/sdk/src/resources/__tests__/attestations.test.ts create mode 100644 packages/sdk/src/resources/attestations-crypto.ts create mode 100644 packages/sdk/src/resources/attestations.ts diff --git a/CLAUDE.md b/CLAUDE.md index 194e88b7..b6ee9fe0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,7 @@ node packages/cli/dist/cli.js ### SDK Resources Defined in `packages/sdk/src/resources/interfaces.ts`: +- `IAttestationsResource` — Privacy Pass Blind RSA token issuance - `ISpendRequestResource` — CRUD + request-approval for spend requests The SDK only accepts credentials. Device authorization, refresh-token @@ -50,7 +51,7 @@ Commands in `packages/cli/src/cli.tsx` (incur framework). Each has two output mo - **Interactive** (default): Ink/React components from `packages/cli/src/commands/` - **JSON** (`--format json`): JSON to stdout, errors as JSON with `code` and `message` fields with exit code 1 -Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `serve`. +Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `attestations request`, `serve`. The CLI also runs as an MCP server (`--mcp`) and serves skill files via `skills` subcommand, both provided by incur. @@ -108,6 +109,16 @@ Key input field notes: - `onboard` — Guided setup: authenticates (skips if already logged in), checks payment methods (prompts to add one if missing, shows picker if multiple), shows app download QR code, then runs the full demo. Requires a TTY. +### attestations command (AAP) + +`attestations request --count [--issuer ] [--access-token ]` — mints Agent Attestation Tokens via the Privacy Pass Blind RSA protocol (token type `0x0002`, RFC 9578 / RFC 9577). Agent-only output. The SDK owns the protocol and API implementation in `packages/sdk/src/resources/attestations.ts` and `attestations-crypto.ts`; CLI schema and registration remain in `packages/cli/src/commands/attestations/`. + +- Discovery: `GET /.well-known/aap-issuer` → metadata, then `GET` its `token_keys` URL. The issuer and every discovered endpoint must use HTTPS on the same DNS origin; redirects and IP-literal hosts are rejected before credentials are sent. +- Tokens use the AAP stable challenge: fixed `issuer_name`, empty `redemption_context`, and empty `origin_info`. +- Blind signatures are verified after unblinding before final tokens are returned. +- Server-side max batch is 100. Issuance requires the `aap:represent` scope. +- Auth: `--access-token`, else `AAP_ACCESS_TOKEN`, else stored CLI credentials. + ### serve command - `serve [--port ] [--host ]` — HTTP server that exposes the CLI's MCP endpoint. Implemented in `packages/cli/src/commands/serve/index.ts`. The handler forwards to `rootCli.fetch()` (incur), but is a **privilege boundary**: `requireAuth` only proves the CLI *owner* is authenticated, not that the HTTP caller is authorized. diff --git a/README.md b/README.md index 0e14c91c..42383a41 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,7 @@ With `--interval`, the login command yields the verification code immediately an ```json { "authenticated": true, - "scope": "userinfo:read payment_methods.agentic", + "scope": "userinfo:read payment_methods.agentic aap:represent", "authorization_details": [{ "type": "source", "actions": ["read"] }], "update": { "current_version": "0.1.2", @@ -275,6 +275,15 @@ Set `NO_UPDATE_NOTIFIER=1` to suppress update checks (for example, in CI). All commands accept `--auth ` to store auth credentials in a specific file instead of the default location. `auth login` writes to this file; all other commands read from it. Useful for running multiple sessions with separate identities. +### Agent attestation tokens + +```bash +link-cli attestations request --count 10 +link-cli attestations request --count 10 --issuer https://api.link.com +``` + +`attestations request` fills an Agent Attestation Token pool using Privacy Pass Blind RSA. It accepts `--count` (1–100), an optional HTTPS `--issuer`, and an optional `--access-token`. Issuer discovery and issuance endpoints must remain on the issuer's HTTPS DNS origin; redirects and IP-literal hosts are rejected. + ### Spend request lifecycle A spend request moves through: **create** → **request approval** → **approved** (with credentials). diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index e042b836..32d11acb 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -1742,7 +1742,9 @@ describe('production mode', () => { ); expect(deviceCodeRequest).toBeDefined(); const params = new URLSearchParams(deviceCodeRequest?.body); - expect(params.get('scope')).toBe('userinfo:read payment_methods.agentic'); + expect(params.get('scope')).toBe( + 'userinfo:read payment_methods.agentic aap:represent', + ); expect(params.get('authorization_details')).toBeNull(); expect(params.getAll('authorization_details[][type]')).toEqual([ 'source', @@ -1830,7 +1832,9 @@ describe('production mode', () => { const params = new URLSearchParams(deviceCodeRequest?.body); expect(params.get('client_hint')).toBe('My Agent'); expect(params.get('connection_label')).toContain('My Agent on '); - expect(params.get('scope')).toBe('userinfo:read payment_methods.agentic'); + expect(params.get('scope')).toBe( + 'userinfo:read payment_methods.agentic aap:represent', + ); // Returns immediately with verification URL and _next hint const output = parseJson(result.stdout) as Record[]; diff --git a/packages/cli/src/auth/__tests__/auth-resource.test.ts b/packages/cli/src/auth/__tests__/auth-resource.test.ts index 4628e2c4..84d4f755 100644 --- a/packages/cli/src/auth/__tests__/auth-resource.test.ts +++ b/packages/cli/src/auth/__tests__/auth-resource.test.ts @@ -72,7 +72,9 @@ describe('LinkAuthResource', () => { const body = mockFetch.mock.calls[0][1].body as string; const params = new URLSearchParams(body); - expect(params.get('scope')).toBe('userinfo:read payment_methods.agentic'); + expect(params.get('scope')).toBe( + 'userinfo:read payment_methods.agentic aap:represent', + ); }); it('passes a custom scope when provided', async () => { diff --git a/packages/cli/src/auth/__tests__/merge-access.test.ts b/packages/cli/src/auth/__tests__/merge-access.test.ts index 1a527591..d276acc5 100644 --- a/packages/cli/src/auth/__tests__/merge-access.test.ts +++ b/packages/cli/src/auth/__tests__/merge-access.test.ts @@ -47,7 +47,9 @@ describe('computeMergedAccess', () => { existingAuthorizationDetails: [], }); - expect(merged.mergedScope).toBe('userinfo:read payment_methods.agentic'); + expect(merged.mergedScope).toBe( + 'userinfo:read payment_methods.agentic aap:represent', + ); }); it('unions source actions across requested and existing (keeps already-granted actions)', () => { diff --git a/packages/cli/src/auth/scopes.ts b/packages/cli/src/auth/scopes.ts index 82ae7df3..4a2fc046 100644 --- a/packages/cli/src/auth/scopes.ts +++ b/packages/cli/src/auth/scopes.ts @@ -1,6 +1,7 @@ export const DEFAULT_SCOPES = [ 'userinfo:read', 'payment_methods.agentic', + 'aap:represent', ] as const; export const DEFAULT_SCOPE = DEFAULT_SCOPES.join(' '); diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index d33a2fd0..99d6cc66 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -1,5 +1,6 @@ import { Cli } from 'incur'; import { type CliAuthStorage, Storage, storage } from './auth/storage'; +import { createAttestationsCli } from './commands/attestations'; import { createAuthCli } from './commands/auth'; import { createBalancesCli } from './commands/balances'; import { createDemoCli } from './commands/demo'; @@ -88,6 +89,7 @@ if (!isAgent && process.stdout.isTTY) { } } +cli.command(createAttestationsCli(() => factory.createAttestationsResource())); cli.command( createAuthCli(authRepo, getUpdateInfo, authStorage, envAccessToken), ); diff --git a/packages/cli/src/commands/attestations/index.tsx b/packages/cli/src/commands/attestations/index.tsx new file mode 100644 index 00000000..a81cd009 --- /dev/null +++ b/packages/cli/src/commands/attestations/index.tsx @@ -0,0 +1,29 @@ +import type { IAttestationsResource } from '@stripe/link-sdk'; +import { Cli } from 'incur'; +import { requestOptions } from './schema'; + +export function createAttestationsCli( + createResource: (accessToken?: string) => IAttestationsResource, +) { + const cli = Cli.create('attestations', { + description: 'Agent Attestation Token (AAT) commands', + }); + + cli.command('request', { + description: + 'Request attestation tokens from an IDP using the Privacy Pass Blind RSA protocol (RFC 9578 type 0x0002). Returns tokens ready for use in Authorization: PrivateToken headers.', + options: requestOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + const { count, issuer, accessToken } = c.options; + const token = accessToken ?? process.env.AAP_ACCESS_TOKEN; + + return createResource(token).request({ + issuer, + count, + }); + }, + }); + + return cli; +} diff --git a/packages/cli/src/commands/attestations/schema.ts b/packages/cli/src/commands/attestations/schema.ts new file mode 100644 index 00000000..b5768f63 --- /dev/null +++ b/packages/cli/src/commands/attestations/schema.ts @@ -0,0 +1,20 @@ +import { z } from 'incur'; + +export const requestOptions = z.object({ + count: z.coerce + .number() + .int() + .positive() + .max(100) + .describe('Number of attestation tokens to request'), + issuer: z + .string() + .default('https://api.link.com') + .describe('Issuer origin URL'), + accessToken: z + .string() + .optional() + .describe( + 'Bearer token for IDP authentication (needs the aap:represent scope). Defaults to the AAP_ACCESS_TOKEN env var, then the stored credentials from "link-cli auth login".', + ), +}); diff --git a/packages/cli/src/utils/__tests__/resource-factory.test.ts b/packages/cli/src/utils/__tests__/resource-factory.test.ts index 5649a90e..9b704821 100644 --- a/packages/cli/src/utils/__tests__/resource-factory.test.ts +++ b/packages/cli/src/utils/__tests__/resource-factory.test.ts @@ -25,6 +25,9 @@ describe('ResourceFactory', () => { const factory = new ResourceFactory(); expect(factory.createAuthResource()).toBe(factory.createAuthResource()); + expect(factory.createAttestationsResource()).toBe( + factory.createAttestationsResource(), + ); expect(factory.createSpendRequestResource()).toBe( factory.createSpendRequestResource(), ); @@ -38,6 +41,7 @@ describe('ResourceFactory', () => { factory.createWebBotAuthResource(), ); expect(factory.createAuthResource()).toBeInstanceOf(LinkAuthResource); + expect(factory.createAttestationsResource().request).toBeTypeOf('function'); expect(factory.createSpendRequestResource().create).toBeTypeOf('function'); expect(factory.createPaymentMethodsResource().list).toBeTypeOf('function'); expect(factory.createBalancesResource().list).toBeTypeOf('function'); diff --git a/packages/cli/src/utils/resource-factory.ts b/packages/cli/src/utils/resource-factory.ts index 86b23349..7848de13 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -1,5 +1,6 @@ import { type AccessTokenProvider, + type IAttestationsResource, type IBalancesResource, type IPaymentMethodsResource, type IReportResource, @@ -70,6 +71,10 @@ interface ResourceFactoryOptions { fetch?: typeof globalThis.fetch; } +type SdkAuthentication = + | { accessToken: string; getAccessToken?: never } + | { accessToken?: never; getAccessToken: AccessTokenProvider }; + function createProxyFetch( baseFetch: typeof globalThis.fetch, proxyUrl: string, @@ -108,6 +113,7 @@ export class ResourceFactory { private _authResource?: IAuthResource; private accessTokenProvider?: ReturnType; private sdkClient?: Link; + private attestationsResource?: IAttestationsResource; private spendRequestResource?: ISpendRequestResource; private paymentMethodsResource?: IPaymentMethodsResource; private shippingAddressResource?: IShippingAddressResource; @@ -134,11 +140,11 @@ export class ResourceFactory { this._authResource = options.authResource; } - private createSdkOptions(getAccessToken: AccessTokenProvider): LinkOptions { + private createSdkOptions(authentication: SdkAuthentication): LinkOptions { return { verbose: this.verbose, defaultHeaders: this.defaultHeaders, - getAccessToken, + ...authentication, apiBaseUrl: this.apiBaseUrl, spendRequestBaseUrl: this.spendRequestBaseUrl, fetch: this.fetch, @@ -214,12 +220,30 @@ export class ResourceFactory { private createSdkClient(): Link { if (!this.sdkClient) { this.sdkClient = new Link( - this.createSdkOptions(this.createSdkAccessTokenProvider()), + this.createSdkOptions({ + getAccessToken: this.createSdkAccessTokenProvider(), + }), ); } return this.sdkClient; } + createAttestationsResource(accessToken?: string): IAttestationsResource { + if (accessToken !== undefined) { + return sanitizeResource( + new Link(this.createSdkOptions({ accessToken })).attestations, + ); + } + if (this.attestationsResource) { + return this.attestationsResource; + } + + this.attestationsResource = sanitizeResource( + this.createSdkClient().attestations, + ); + return this.attestationsResource; + } + createSpendRequestResource(): ISpendRequestResource { if (this.spendRequestResource) { return this.spendRequestResource; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 32c16e5f..f87b1b9d 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -1,6 +1,8 @@ import type { LinkOptions } from '@/config'; +import { AttestationsResource } from '@/resources/attestations'; import { BalancesResource } from '@/resources/balances'; import type { + IAttestationsResource, IBalancesResource, IPaymentMethodsResource, IReportResource, @@ -21,6 +23,7 @@ import { UserInfoResource } from '@/resources/user-info'; import { WebBotAuthResource } from '@/resources/web-bot-auth'; export class Link { + readonly attestations: IAttestationsResource; readonly spendRequests: ISpendRequestResource; readonly paymentMethods: IPaymentMethodsResource; readonly shippingAddresses: IShippingAddressResource; @@ -32,6 +35,7 @@ export class Link { readonly reports: IReportResource; constructor(options: LinkOptions) { + this.attestations = new AttestationsResource(options); this.spendRequests = new SpendRequestResource(options); this.paymentMethods = new PaymentMethodsResource(options); this.shippingAddresses = new ShippingAddressResource(options); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index a987ade7..4ae61eb0 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -9,4 +9,5 @@ export { } from './errors'; export * from './types/index'; export * from './resources/interfaces'; +export * from './resources/attestations'; export { getDuplicateSpendRequest } from './resources/spend-request'; diff --git a/packages/sdk/src/resources/__tests__/attestations-crypto.test.ts b/packages/sdk/src/resources/__tests__/attestations-crypto.test.ts new file mode 100644 index 00000000..88cc33bd --- /dev/null +++ b/packages/sdk/src/resources/__tests__/attestations-crypto.test.ts @@ -0,0 +1,92 @@ +import { generateKeyPairSync, randomBytes } from 'node:crypto'; +import { + generateBlindedMessages, + unblindSignatures, +} from '@/resources/attestations-crypto'; +import { describe, expect, it } from 'vitest'; + +function bytesToBigInt(bytes: Uint8Array): bigint { + return BigInt(`0x${Buffer.from(bytes).toString('hex')}`); +} + +function bigIntToBytes(value: bigint, length: number): Uint8Array { + return new Uint8Array( + Buffer.from(value.toString(16).padStart(length * 2, '0'), 'hex'), + ); +} + +function modPow(base: bigint, exponent: bigint, modulus: bigint): bigint { + let result = 1n; + let current = base % modulus; + let remaining = exponent; + while (remaining > 0n) { + if (remaining & 1n) { + result = (result * current) % modulus; + } + remaining >>= 1n; + current = (current * current) % modulus; + } + return result; +} + +function decodeJwkInteger(value: string): bigint { + return bytesToBigInt(new Uint8Array(Buffer.from(value, 'base64url'))); +} + +describe('Blind RSA finalization', () => { + const { publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: 1024, + publicExponent: 0x10001, + }); + const spki = new Uint8Array( + publicKey.export({ format: 'der', type: 'spki' }), + ); + const privateJwk = privateKey.export({ format: 'jwk' }); + const modulus = decodeJwkInteger(privateJwk.n as string); + const privateExponent = decodeJwkInteger(privateJwk.d as string); + + it('accepts a correctly signed blinded message', () => { + const state = generateBlindedMessages( + spki, + 1, + new Uint8Array(randomBytes(32)), + ); + const token = state.tokens[0]; + expect(token).toBeDefined(); + if (!token) { + throw new Error('Expected one blinded token'); + } + const blindedMessage = bytesToBigInt(token.blindedMsg); + const blindSignature = bigIntToBytes( + modPow(blindedMessage, privateExponent, modulus), + token.blindedMsg.length, + ); + + const tokens = unblindSignatures(state, [ + Buffer.from(blindSignature).toString('base64url'), + ]); + + expect(tokens).toHaveLength(1); + expect(tokens[0]?.raw).toHaveLength(2 + 32 + 32 + 32 + 128); + }); + + it('rejects an invalid blind signature after unblinding', () => { + const state = generateBlindedMessages( + spki, + 1, + new Uint8Array(randomBytes(32)), + ); + const token = state.tokens[0]; + expect(token).toBeDefined(); + if (!token) { + throw new Error('Expected one blinded token'); + } + const invalidSignature = Buffer.alloc(token.blindedMsg.length).toString( + 'base64url', + ); + + expect(() => unblindSignatures(state, [invalidSignature])).toThrow( + 'Blind signature 0 failed verification', + ); + }); +}); diff --git a/packages/sdk/src/resources/__tests__/attestations.test.ts b/packages/sdk/src/resources/__tests__/attestations.test.ts new file mode 100644 index 00000000..205eb2f1 --- /dev/null +++ b/packages/sdk/src/resources/__tests__/attestations.test.ts @@ -0,0 +1,133 @@ +import { generateKeyPairSync } from 'node:crypto'; +import { LinkResponseError } from '@/errors'; +import { AttestationsResource } from '@/resources/attestations'; +import { describe, expect, it, vi } from 'vitest'; + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('AttestationsResource', () => { + it('rejects non-HTTPS and IP-literal issuers before fetching', async () => { + const fetchMock = vi.fn(); + const resource = new AttestationsResource({ + accessToken: 'secret', + fetch: fetchMock, + }); + + await expect( + resource.request({ issuer: 'http://127.0.0.1', count: 1 }), + ).rejects.toThrow('Issuer must be an HTTPS origin with a DNS hostname'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects off-origin metadata before sending the access token', async () => { + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + jsonResponse({ + issuer: 'https://issuer.example', + token_issuance_endpoint: 'https://attacker.example/issue', + token_keys: 'https://issuer.example/token-keys', + }), + ); + const resource = new AttestationsResource({ + accessToken: 'secret', + fetch: fetchMock, + }); + + await expect( + resource.request({ issuer: 'https://issuer.example', count: 1 }), + ).rejects.toThrow('token_issuance_endpoint must be an HTTPS URL'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[1]).toEqual({ redirect: 'manual' }); + }); + + it('refuses discovery redirects', async () => { + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response('', { + status: 302, + headers: { Location: 'https://attacker.example/metadata' }, + }), + ); + const resource = new AttestationsResource({ + accessToken: 'secret', + fetch: fetchMock, + }); + + await expect( + resource.request({ issuer: 'https://issuer.example', count: 1 }), + ).rejects.toThrow('Refused redirect'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('wraps malformed issuer metadata in LinkResponseError', async () => { + const resource = new AttestationsResource({ + accessToken: 'secret', + fetch: vi.fn(async () => jsonResponse({ issuer: 42 })), + }); + + await expect( + resource.request({ issuer: 'https://issuer.example', count: 1 }), + ).rejects.toBeInstanceOf(LinkResponseError); + }); + + it('refreshes LinkOptions authentication after an issuance 401', async () => { + const { publicKey } = generateKeyPairSync('rsa', { + modulusLength: 1024, + publicExponent: 0x10001, + }); + const tokenKey = publicKey + .export({ format: 'der', type: 'spki' }) + .toString('base64'); + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.endsWith('/.well-known/aap-issuer')) { + return jsonResponse({ + issuer: 'https://issuer.example', + token_issuance_endpoint: 'https://issuer.example/issue', + token_keys: 'https://issuer.example/token-keys', + }); + } + if (url.endsWith('/token-keys')) { + return jsonResponse({ + 'token-keys': [{ 'token-type': 0x0002, 'token-key': tokenKey }], + }); + } + return jsonResponse( + { error: `unauthorized: ${String(init?.headers)}` }, + 401, + ); + }, + ); + const getAccessToken = vi.fn( + ({ forceRefresh }: { forceRefresh?: boolean } = {}) => + forceRefresh ? 'refreshed-token' : 'initial-token', + ); + const resource = new AttestationsResource({ + getAccessToken, + fetch: fetchMock, + }); + + await expect( + resource.request({ issuer: 'https://issuer.example', count: 1 }), + ).rejects.toThrow('Failed to issue attestation tokens (401)'); + + expect(getAccessToken).toHaveBeenNthCalledWith(1, undefined); + expect(getAccessToken).toHaveBeenNthCalledWith(2, { forceRefresh: true }); + expect(fetchMock.mock.calls[2]?.[1]).toMatchObject({ + headers: expect.objectContaining({ + Authorization: 'Bearer initial-token', + }), + }); + expect(fetchMock.mock.calls[3]?.[1]).toMatchObject({ + headers: expect.objectContaining({ + Authorization: 'Bearer refreshed-token', + }), + }); + }); +}); diff --git a/packages/sdk/src/resources/__tests__/factory.test.ts b/packages/sdk/src/resources/__tests__/factory.test.ts index 8ecd8b96..dc1a2fed 100644 --- a/packages/sdk/src/resources/__tests__/factory.test.ts +++ b/packages/sdk/src/resources/__tests__/factory.test.ts @@ -1,4 +1,5 @@ import Link from '@/client'; +import { AttestationsResource } from '@/resources/attestations'; import { PaymentMethodsResource } from '@/resources/payment-methods'; import { ReportResource } from '@/resources/report'; import { SpendRequestResource } from '@/resources/spend-request'; @@ -14,6 +15,7 @@ describe('Link', () => { apiBaseUrl: 'https://api.example.com', }); + expect(client.attestations).toBeInstanceOf(AttestationsResource); expect(client.spendRequests).toBeInstanceOf(SpendRequestResource); expect(client.paymentMethods).toBeInstanceOf(PaymentMethodsResource); expect(client.transactions).toBeInstanceOf(TransactionsResource); @@ -22,6 +24,7 @@ describe('Link', () => { expect(client.spendRequests.create).toBeTypeOf('function'); expect(client.spendRequests.update).toBeTypeOf('function'); expect(client.spendRequests.retrieve).toBeTypeOf('function'); + expect(client.attestations.request).toBeTypeOf('function'); expect(client.paymentMethods.list).toBeTypeOf('function'); expect(client.transactions.list).toBeTypeOf('function'); }); diff --git a/packages/sdk/src/resources/attestations-crypto.ts b/packages/sdk/src/resources/attestations-crypto.ts new file mode 100644 index 00000000..bef54e60 --- /dev/null +++ b/packages/sdk/src/resources/attestations-crypto.ts @@ -0,0 +1,395 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; + +const TOKEN_TYPE = 0x0002; +const NONCE_SIZE = 32; +const CHALLENGE_DIGEST_SIZE = 32; +const TOKEN_KEY_ID_SIZE = 32; + +interface RsaPublicKey { + n: bigint; + e: bigint; + nLen: number; +} + +interface BlindedToken { + nonce: Uint8Array; + blindedMsg: Uint8Array; + blindInverse: bigint; + encodedMessage: Uint8Array; +} + +export interface BlindingState { + tokens: BlindedToken[]; + publicKey: RsaPublicKey; + challengeDigest: Uint8Array; + tokenKeyId: Uint8Array; +} + +export interface FinalToken { + raw: Uint8Array; + base64url: string; +} + +export function base64urlEncode(buf: Uint8Array): string { + return Buffer.from(buf).toString('base64url'); +} + +function base64urlDecode(str: string): Uint8Array { + return new Uint8Array(Buffer.from(str, 'base64url')); +} + +function bytesToBigInt(bytes: Uint8Array): bigint { + let hex = ''; + for (const b of bytes) { + hex += b.toString(16).padStart(2, '0'); + } + return hex.length === 0 ? 0n : BigInt(`0x${hex}`); +} + +function bigIntToBytes(n: bigint, length: number): Uint8Array { + const hex = n.toString(16).padStart(length * 2, '0'); + const bytes = new Uint8Array(length); + for (let i = 0; i < length; i++) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function modPow(base: bigint, exp: bigint, mod: bigint): bigint { + let result = 1n; + let b = ((base % mod) + mod) % mod; + let e = exp; + while (e > 0n) { + if (e & 1n) { + result = (result * b) % mod; + } + e >>= 1n; + b = (b * b) % mod; + } + return result; +} + +function modInverse(a: bigint, m: bigint): bigint { + let [oldR, r] = [a, m]; + let [oldS, s] = [1n, 0n]; + while (r !== 0n) { + const q = oldR / r; + [oldR, r] = [r, oldR - q * r]; + [oldS, s] = [s, oldS - q * s]; + } + return ((oldS % m) + m) % m; +} + +function parseSpkiPublicKey(spkiDer: Uint8Array): RsaPublicKey { + // SubjectPublicKeyInfo ::= SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING } + // RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER } + // + // The DER must be walked structurally, not scanned for tag bytes: an + // id-RSASSA-PSS AlgorithmIdentifier carries nested hash/MGF1/saltLength + // parameters whose bytes include values that look like BIT STRING and + // INTEGER tags. + const spki = readSequence(spkiDer, 0); + + // Skip the AlgorithmIdentifier, then read the BIT STRING that follows it. + const algorithm = readTlv(spkiDer, spki.contentStart); + const bitString = readTlv(spkiDer, algorithm.end); + if (bitString.tag !== 0x03) { + throw new Error( + `Expected BIT STRING in SPKI, got 0x${bitString.tag.toString(16)}`, + ); + } + + // First content byte of a BIT STRING is the unused-bits count (0 here). + const rsaPublicKeyDer = spkiDer.slice( + bitString.contentStart + 1, + bitString.end, + ); + + const rsaPublicKey = readSequence(rsaPublicKeyDer, 0); + const modulus = readInteger(rsaPublicKeyDer, rsaPublicKey.contentStart); + const exponent = readInteger(rsaPublicKeyDer, modulus.end); + + return { + n: bytesToBigInt(modulus.value), + e: bytesToBigInt(exponent.value), + nLen: modulus.value.length, + }; +} + +interface Tlv { + tag: number; + contentStart: number; + end: number; +} + +function readTlv(data: Uint8Array, offset: number): Tlv { + if (offset >= data.length) { + throw new Error(`Unexpected end of DER at offset ${offset}`); + } + const tag = data[offset]; + if (tag === undefined) { + throw new Error(`Unexpected end of DER at offset ${offset}`); + } + const { value: length, bytesRead } = parseDerLength(data, offset + 1); + const contentStart = offset + 1 + bytesRead; + const end = contentStart + length; + if (end > data.length) { + throw new Error(`DER element at offset ${offset} overruns the buffer`); + } + return { tag, contentStart, end }; +} + +function readSequence(data: Uint8Array, offset: number): Tlv { + const tlv = readTlv(data, offset); + if (tlv.tag !== 0x30) { + throw new Error( + `Expected SEQUENCE at offset ${offset}, got 0x${tlv.tag.toString(16)}`, + ); + } + return tlv; +} + +function readInteger( + data: Uint8Array, + offset: number, +): { value: Uint8Array; end: number } { + const tlv = readTlv(data, offset); + if (tlv.tag !== 0x02) { + throw new Error( + `Expected INTEGER at offset ${offset}, got 0x${tlv.tag.toString(16)}`, + ); + } + let value = data.slice(tlv.contentStart, tlv.end); + // Strip the DER sign byte. + if (value.length > 1 && value[0] === 0x00) { + value = value.slice(1); + } + return { value, end: tlv.end }; +} + +function parseDerLength( + data: Uint8Array, + offset: number, +): { value: number; bytesRead: number } { + const first = data[offset]; + if (first === undefined) { + throw new Error(`Unexpected end of DER at offset ${offset}`); + } + if (first < 0x80) { + return { value: first, bytesRead: 1 }; + } + const numBytes = first & 0x7f; + if (numBytes === 0 || offset + numBytes >= data.length) { + throw new Error(`Invalid DER length at offset ${offset}`); + } + let value = 0; + for (let i = 0; i < numBytes; i++) { + const byte = data[offset + 1 + i]; + if (byte === undefined) { + throw new Error(`Unexpected end of DER at offset ${offset + 1 + i}`); + } + value = value * 256 + byte; + } + return { value, bytesRead: 1 + numBytes }; +} + +// EMSA-PSS encoding for RSA-PSS (RFC 8017 §9.1.1) with SHA-384 +function emsaPssEncode(message: Uint8Array, emBits: number): Uint8Array { + const hashAlg = 'sha384'; + const hLen = 48; // SHA-384 output + const sLen = 48; // salt length = hash length for RSABSSA-SHA384-PSS + const emLen = Math.ceil(emBits / 8); + + const mHash = createHash(hashAlg).update(message).digest(); + if (emLen < hLen + sLen + 2) { + throw new Error('Encoding error: emLen too small'); + } + + const salt = randomBytes(sLen); + // M' = (0x)00 00 00 00 00 00 00 00 || mHash || salt + const mPrime = Buffer.concat([Buffer.alloc(8), mHash, salt]); + const h = createHash(hashAlg).update(mPrime).digest(); + + const ps = Buffer.alloc(emLen - sLen - hLen - 2); + const db = Buffer.concat([ps, Buffer.from([0x01]), salt]); + + const dbMask = mgf1(h, db.length, hashAlg); + const maskedDb = Buffer.alloc(db.length); + for (let i = 0; i < db.length; i++) { + maskedDb.writeUInt8(db.readUInt8(i) ^ dbMask.readUInt8(i), i); + } + + // Set the leftmost bits to zero. + const topBits = 8 * emLen - emBits; + maskedDb.writeUInt8(maskedDb.readUInt8(0) & (0xff >> topBits), 0); + + return new Uint8Array(Buffer.concat([maskedDb, h, Buffer.from([0xbc])])); +} + +function mgf1(seed: Buffer, length: number, hashAlg: string): Buffer { + const hLen = hashAlg === 'sha384' ? 48 : 32; + const result = Buffer.alloc(length); + let offset = 0; + let counter = 0; + + while (offset < length) { + const c = Buffer.alloc(4); + c.writeUInt32BE(counter); + const hash = createHash(hashAlg).update(seed).update(c).digest(); + const toCopy = Math.min(hLen, length - offset); + hash.copy(result, offset, 0, toCopy); + offset += toCopy; + counter++; + } + + return result; +} + +function generateBlindingFactor( + n: bigint, + nLen: number, +): { r: bigint; rInv: bigint } { + while (true) { + const rBytes = randomBytes(nLen); + rBytes[0] = 0; + const r = bytesToBigInt(new Uint8Array(rBytes)); + if (r <= 1n || r >= n) continue; + const rInv = modInverse(r, n); + if ((r * rInv) % n === 1n) { + return { r, rInv }; + } + } +} + +export function generateBlindedMessages( + spkiDer: Uint8Array, + count: number, + challengeDigest: Uint8Array, +): BlindingState { + const publicKey = parseSpkiPublicKey(spkiDer); + const tokenKeyId = new Uint8Array( + createHash('sha256').update(spkiDer).digest(), + ); + + const emBits = publicKey.nLen * 8 - 1; + const tokens: BlindedToken[] = []; + + for (let i = 0; i < count; i++) { + const nonce = new Uint8Array(randomBytes(NONCE_SIZE)); + const tokenInput = new Uint8Array( + 2 + NONCE_SIZE + CHALLENGE_DIGEST_SIZE + TOKEN_KEY_ID_SIZE, + ); + tokenInput[0] = (TOKEN_TYPE >> 8) & 0xff; + tokenInput[1] = TOKEN_TYPE & 0xff; + tokenInput.set(nonce, 2); + tokenInput.set(challengeDigest, 2 + NONCE_SIZE); + tokenInput.set(tokenKeyId, 2 + NONCE_SIZE + CHALLENGE_DIGEST_SIZE); + + const encoded = emsaPssEncode(tokenInput, emBits); + const message = bytesToBigInt(encoded); + const { r, rInv } = generateBlindingFactor(publicKey.n, publicKey.nLen); + const blindedMessage = + (message * modPow(r, publicKey.e, publicKey.n)) % publicKey.n; + + tokens.push({ + nonce, + blindedMsg: bigIntToBytes(blindedMessage, publicKey.nLen), + blindInverse: rInv, + encodedMessage: encoded, + }); + } + + return { tokens, publicKey, challengeDigest, tokenKeyId }; +} + +export function unblindSignatures( + state: BlindingState, + blindSigs: string[], +): FinalToken[] { + const { tokens, publicKey, tokenKeyId } = state; + + if (blindSigs.length !== tokens.length) { + throw new Error( + `Expected ${tokens.length} blind signatures, got ${blindSigs.length}`, + ); + } + + const results: FinalToken[] = []; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const blindSignature = blindSigs[i]; + if (!token || blindSignature === undefined) { + throw new Error(`Missing blind signature state at index ${i}`); + } + const blindSigBytes = base64urlDecode(blindSignature); + const blindSigInt = bytesToBigInt(blindSigBytes); + const sigInt = (blindSigInt * token.blindInverse) % publicKey.n; + const authenticator = bigIntToBytes(sigInt, publicKey.nLen); + const recoveredMessage = bigIntToBytes( + modPow(sigInt, publicKey.e, publicKey.n), + publicKey.nLen, + ); + if ( + !timingSafeEqual( + Buffer.from(recoveredMessage), + Buffer.from(token.encodedMessage), + ) + ) { + throw new Error(`Blind signature ${i} failed verification`); + } + + const raw = new Uint8Array( + 2 + + NONCE_SIZE + + CHALLENGE_DIGEST_SIZE + + TOKEN_KEY_ID_SIZE + + publicKey.nLen, + ); + raw[0] = (TOKEN_TYPE >> 8) & 0xff; + raw[1] = TOKEN_TYPE & 0xff; + raw.set(token.nonce, 2); + raw.set(state.challengeDigest, 2 + NONCE_SIZE); + raw.set(tokenKeyId, 2 + NONCE_SIZE + CHALLENGE_DIGEST_SIZE); + raw.set( + authenticator, + 2 + NONCE_SIZE + CHALLENGE_DIGEST_SIZE + TOKEN_KEY_ID_SIZE, + ); + + results.push({ + raw, + base64url: base64urlEncode(raw), + }); + } + + return results; +} + +export function computeChallengeDigest( + tokenType: number, + issuerName: string, +): Uint8Array { + const issuerBytes = Buffer.from(issuerName, 'utf-8'); + const originBytes = Buffer.alloc(0); + + const challenge = Buffer.alloc( + 2 + 2 + issuerBytes.length + 1 + 2 + originBytes.length, + ); + let offset = 0; + + challenge.writeUInt16BE(tokenType, offset); + offset += 2; + challenge.writeUInt16BE(issuerBytes.length, offset); + offset += 2; + issuerBytes.copy(challenge, offset); + offset += issuerBytes.length; + challenge.writeUInt8(0, offset); + offset += 1; + challenge.writeUInt16BE(originBytes.length, offset); + offset += 2; + if (originBytes.length > 0) { + originBytes.copy(challenge, offset); + } + + return new Uint8Array(createHash('sha256').update(challenge).digest()); +} diff --git a/packages/sdk/src/resources/attestations.ts b/packages/sdk/src/resources/attestations.ts new file mode 100644 index 00000000..66bb42dc --- /dev/null +++ b/packages/sdk/src/resources/attestations.ts @@ -0,0 +1,379 @@ +import { createHash } from 'node:crypto'; +import { isIP } from 'node:net'; +import type { LinkOptions } from '@/config'; +import { + LinkApiError, + LinkConfigurationError, + LinkResponseError, + LinkTransportError, +} from '@/errors'; +import { + type FinalToken, + base64urlEncode, + computeChallengeDigest, + generateBlindedMessages, + unblindSignatures, +} from '@/resources/attestations-crypto'; +import { BaseResource } from '@/resources/base'; +import type { + AttestationRequestParams, + AttestationRequestResult, + IAttestationsResource, +} from '@/resources/interfaces'; +import { z } from 'zod'; + +const TOKEN_TYPE_BLIND_RSA = 0x0002; +const CONTENT_TYPE_TOKEN_REQUEST = 'application/private-token-request'; +const CONTENT_TYPE_TOKEN_RESPONSE = 'application/private-token-response'; + +const issuerMetadataSchema = z.looseObject({ + issuer: z.string(), + token_issuance_endpoint: z.string(), + token_keys: z.string(), +}); + +const tokenKeyDirectorySchema = z.looseObject({ + 'token-keys': z.array( + z.looseObject({ + 'token-type': z.number(), + 'token-key': z.string(), + }), + ), +}); + +function base64ToBytes(value: string): Uint8Array { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + return new Uint8Array(Buffer.from(normalized, 'base64')); +} + +function encodeBatchTokenRequest( + blindedMessages: Uint8Array[], + truncatedTokenKeyId: number, +): Buffer { + const entries = blindedMessages.map((blindedMessage) => { + const entry = Buffer.alloc(3 + blindedMessage.length); + entry.writeUInt16BE(TOKEN_TYPE_BLIND_RSA, 0); + entry.writeUInt8(truncatedTokenKeyId, 2); + Buffer.from(blindedMessage).copy(entry, 3); + return entry; + }); + + const vector = Buffer.concat(entries); + const prefix = Buffer.alloc(2); + prefix.writeUInt16BE(vector.length, 0); + return Buffer.concat([prefix, vector]); +} + +function decodeBatchTokenResponse( + body: Buffer, + elementSize: number, + expectedCount: number, +): string[] { + if (body.length < 2) { + throw new Error( + `BatchTokenResponse too short: ${body.length} bytes (expected at least 2)`, + ); + } + + const vectorLength = body.readUInt16BE(0); + const vector = body.subarray(2); + if (vector.length !== vectorLength) { + throw new Error( + `BatchTokenResponse length prefix says ${vectorLength} bytes but ${vector.length} bytes follow`, + ); + } + if (vectorLength === 0 || vectorLength % elementSize !== 0) { + throw new Error( + `BatchTokenResponse vector of ${vectorLength} bytes is not a multiple of the ${elementSize}-byte element size`, + ); + } + + const count = vectorLength / elementSize; + if (count !== expectedCount) { + throw new Error( + `Issuer returned ${count} blind signatures for ${expectedCount} token requests`, + ); + } + + return Array.from({ length: count }, (_, index) => + base64urlEncode( + new Uint8Array( + vector.subarray(index * elementSize, (index + 1) * elementSize), + ), + ), + ); +} + +function parseIssuerOrigin(issuer: string): URL { + let url: URL; + try { + url = new URL(issuer); + } catch (error) { + throw new LinkConfigurationError(`Invalid issuer URL: ${issuer}`, { + cause: error, + }); + } + const hostname = url.hostname.replace(/^\[|\]$/g, ''); + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash || + isIP(hostname) !== 0 + ) { + throw new LinkConfigurationError( + 'Issuer must be an HTTPS origin with a DNS hostname', + ); + } + return url; +} + +function requireIssuerEndpoint( + value: string, + issuerOrigin: string, + field: string, +): string { + let url: URL; + try { + url = new URL(value); + } catch (error) { + throw new TypeError(`${field} is not a valid URL`, { cause: error }); + } + const hostname = url.hostname.replace(/^\[|\]$/g, ''); + if ( + url.protocol !== 'https:' || + url.origin !== issuerOrigin || + url.username || + url.password || + isIP(hostname) !== 0 + ) { + throw new TypeError(`${field} must be an HTTPS URL on the issuer origin`); + } + return url.href; +} + +export class AttestationsResource + extends BaseResource + implements IAttestationsResource +{ + constructor(options: LinkOptions) { + super(options, ''); + } + + private async fetchJson( + url: string, + operation: string, + ): Promise<{ data: unknown; status: number }> { + let response: Response; + try { + response = await this.fetchImpl(url, { redirect: 'manual' }); + } catch (error) { + throw new LinkTransportError(`Request failed: GET ${url}`, { + cause: error, + }); + } + + const rawBody = await response.text(); + if (response.status >= 300 && response.status < 400) { + throw new LinkApiError( + `Refused redirect while attempting to ${operation} (${response.status})`, + { + status: response.status, + rawBody, + }, + ); + } + let data: unknown = null; + try { + data = JSON.parse(rawBody); + } catch (error) { + if (response.ok) { + throw new LinkResponseError(operation, response.status, { + cause: error, + }); + } + } + + if (!response.ok) { + throw new LinkApiError( + `Failed to ${operation} (${response.status}): ${rawBody}`, + { + status: response.status, + rawBody, + details: data, + }, + ); + } + return { data, status: response.status }; + } + + private async issueTokens( + url: string, + body: Uint8Array, + forceRefresh = false, + ): Promise { + const token = await this.getAccessToken( + forceRefresh ? { forceRefresh: true } : undefined, + ); + const requestBody = new ArrayBuffer(body.byteLength); + new Uint8Array(requestBody).set(body); + try { + return await this.fetchImpl(url, { + method: 'POST', + redirect: 'manual', + headers: { + 'Content-Type': CONTENT_TYPE_TOKEN_REQUEST, + Accept: CONTENT_TYPE_TOKEN_RESPONSE, + Authorization: `Bearer ${token}`, + }, + body: requestBody, + }); + } catch (error) { + throw new LinkTransportError(`Request failed: POST ${url}`, { + cause: error, + }); + } + } + + async request( + params: AttestationRequestParams, + ): Promise { + const { issuer, count } = params; + if (!Number.isInteger(count) || count < 1 || count > 100) { + throw new LinkConfigurationError( + 'Attestation token count must be an integer from 1 to 100', + ); + } + const issuerUrl = parseIssuerOrigin(issuer); + const metadataUrl = new URL('/.well-known/aap-issuer', issuerUrl).href; + const metadataResponse = await this.fetchJson( + metadataUrl, + 'fetch issuer metadata', + ); + const metadata = this.parseResponse( + 'parse issuer metadata', + metadataResponse.status, + () => issuerMetadataSchema.parse(metadataResponse.data), + ); + let tokenKeysUrl: string; + let issuanceUrl: string; + try { + const metadataIssuerUrl = parseIssuerOrigin(metadata.issuer); + if (metadataIssuerUrl.origin !== issuerUrl.origin) { + throw new TypeError( + 'issuer metadata identifier must match the discovery origin', + ); + } + tokenKeysUrl = requireIssuerEndpoint( + metadata.token_keys, + issuerUrl.origin, + 'token_keys', + ); + issuanceUrl = requireIssuerEndpoint( + metadata.token_issuance_endpoint, + issuerUrl.origin, + 'token_issuance_endpoint', + ); + } catch (error) { + throw new LinkResponseError( + 'validate issuer metadata', + metadataResponse.status, + { cause: error }, + ); + } + const directoryResponse = await this.fetchJson( + tokenKeysUrl, + 'fetch token keys', + ); + const directory = this.parseResponse( + 'parse token key directory', + directoryResponse.status, + () => tokenKeyDirectorySchema.parse(directoryResponse.data), + ); + const tokenKey = directory['token-keys'].find( + (entry) => entry['token-type'] === TOKEN_TYPE_BLIND_RSA, + ); + if (!tokenKey) { + throw new LinkResponseError( + 'select Blind RSA token key', + directoryResponse.status, + { + cause: new Error('No token key with type 0x0002 found in directory'), + }, + ); + } + + const spkiDer = base64ToBytes(tokenKey['token-key']); + const challengeDigest = computeChallengeDigest( + TOKEN_TYPE_BLIND_RSA, + new URL(metadata.issuer).hostname, + ); + const blindingState = generateBlindedMessages( + spkiDer, + count, + challengeDigest, + ); + const tokenKeyIdBytes = new Uint8Array( + createHash('sha256').update(spkiDer).digest(), + ); + const truncatedTokenKeyId = tokenKeyIdBytes.at(-1); + if (truncatedTokenKeyId === undefined) { + throw new LinkResponseError('derive Blind RSA token key ID', 200); + } + const requestBody = encodeBatchTokenRequest( + blindingState.tokens.map((token) => token.blindedMsg), + truncatedTokenKeyId, + ); + + let issueResponse = await this.issueTokens( + issuanceUrl, + new Uint8Array(requestBody), + ); + if (issueResponse.status === 401 && this.canRefreshAccessToken) { + issueResponse = await this.issueTokens( + issuanceUrl, + new Uint8Array(requestBody), + true, + ); + } + + if (!issueResponse.ok) { + const rawBody = await issueResponse.text(); + throw new LinkApiError( + `Failed to issue attestation tokens (${issueResponse.status}): ${rawBody}`, + { + status: issueResponse.status, + rawBody, + }, + ); + } + + let blindSignatures: string[]; + try { + blindSignatures = decodeBatchTokenResponse( + Buffer.from(await issueResponse.arrayBuffer()), + blindingState.publicKey.nLen, + count, + ); + } catch (error) { + throw new LinkResponseError( + 'decode attestation token response', + issueResponse.status, + { cause: error }, + ); + } + const finalTokens: FinalToken[] = unblindSignatures( + blindingState, + blindSignatures, + ); + + return { + tokens: finalTokens.map((finalToken) => finalToken.base64url), + issuer: metadata.issuer, + token_key_id: base64urlEncode(blindingState.tokenKeyId), + count: finalTokens.length, + }; + } +} diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index e9382d88..0416564a 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -23,6 +23,22 @@ export type AccessTokenProvider = ( options?: GetAccessTokenOptions, ) => Promise | string; +export interface AttestationRequestParams { + issuer: string; + count: number; +} + +export interface AttestationRequestResult { + tokens: string[]; + issuer: string; + token_key_id: string; + count: number; +} + +export interface IAttestationsResource { + request(params: AttestationRequestParams): Promise; +} + export interface CreateSpendRequestParams { payment_details?: string; credential_type?: CredentialType; diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index f8afe8de..ec794d32 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -71,6 +71,7 @@ Call `tools/list` to see all available MCP tools. - By default all output is in `toon` format. Pass `--format [json|md|yaml]` to change output format. - Some commands return a verification or approval URL. **These** must be presented to the user clearly for their action. - `--auth ` flag to store auth credentials in a specific file instead of the default location. `auth login` writes to this file; all other commands read from it. Example: `link-cli auth login --auth credentials.json` +- Agent Attestation Tokens are separate from the payment flow. When explicitly needed, request a pool with `link-cli attestations request --count <1-100> [--issuer ]`. _Recommended_: Run `link-cli --llms` to understand all the available commands. The `--llms-full` output is the canonical reference for parameter names, types, and valid values. Pass `--schema` before invoking a command to understand its parameters and constraints. From c31b99038ceee9e683cf37f34218108095d0adf7 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Wed, 26 Aug 2026 18:15:33 -0400 Subject: [PATCH 02/26] fix: stop requesting AAP representation scope The backend no longer requires aap:represent, so keep default login grants minimal and remove the obsolete requirement from command guidance. Co-authored-by: Cursor Committed-By-Agent: cursor --- CLAUDE.md | 2 +- README.md | 4 ++-- packages/cli/src/__tests__/cli.test.ts | 8 ++------ packages/cli/src/auth/__tests__/auth-resource.test.ts | 4 +--- packages/cli/src/auth/__tests__/merge-access.test.ts | 4 +--- packages/cli/src/auth/scopes.ts | 1 - packages/cli/src/commands/attestations/schema.ts | 2 +- skills/create-payment-credential/SKILL.md | 2 +- 8 files changed, 9 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b6ee9fe0..b50fa650 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,7 @@ Key input field notes: - Discovery: `GET /.well-known/aap-issuer` → metadata, then `GET` its `token_keys` URL. The issuer and every discovered endpoint must use HTTPS on the same DNS origin; redirects and IP-literal hosts are rejected before credentials are sent. - Tokens use the AAP stable challenge: fixed `issuer_name`, empty `redemption_context`, and empty `origin_info`. - Blind signatures are verified after unblinding before final tokens are returned. -- Server-side max batch is 100. Issuance requires the `aap:represent` scope. +- Server-side max batch is 100. Issuance does not require an AAP-specific OAuth scope. - Auth: `--access-token`, else `AAP_ACCESS_TOKEN`, else stored CLI credentials. ### serve command diff --git a/README.md b/README.md index 42383a41..50ed7540 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,7 @@ With `--interval`, the login command yields the verification code immediately an ```json { "authenticated": true, - "scope": "userinfo:read payment_methods.agentic aap:represent", + "scope": "userinfo:read payment_methods.agentic", "authorization_details": [{ "type": "source", "actions": ["read"] }], "update": { "current_version": "0.1.2", @@ -282,7 +282,7 @@ link-cli attestations request --count 10 link-cli attestations request --count 10 --issuer https://api.link.com ``` -`attestations request` fills an Agent Attestation Token pool using Privacy Pass Blind RSA. It accepts `--count` (1–100), an optional HTTPS `--issuer`, and an optional `--access-token`. Issuer discovery and issuance endpoints must remain on the issuer's HTTPS DNS origin; redirects and IP-literal hosts are rejected. +`attestations request` fills an Agent Attestation Token pool using Privacy Pass Blind RSA. It accepts `--count` (1–100), an optional HTTPS `--issuer`, and an optional `--access-token`; no AAP-specific OAuth scope is required. Issuer discovery and issuance endpoints must remain on the issuer's HTTPS DNS origin; redirects and IP-literal hosts are rejected. ### Spend request lifecycle diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 32d11acb..e042b836 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -1742,9 +1742,7 @@ describe('production mode', () => { ); expect(deviceCodeRequest).toBeDefined(); const params = new URLSearchParams(deviceCodeRequest?.body); - expect(params.get('scope')).toBe( - 'userinfo:read payment_methods.agentic aap:represent', - ); + expect(params.get('scope')).toBe('userinfo:read payment_methods.agentic'); expect(params.get('authorization_details')).toBeNull(); expect(params.getAll('authorization_details[][type]')).toEqual([ 'source', @@ -1832,9 +1830,7 @@ describe('production mode', () => { const params = new URLSearchParams(deviceCodeRequest?.body); expect(params.get('client_hint')).toBe('My Agent'); expect(params.get('connection_label')).toContain('My Agent on '); - expect(params.get('scope')).toBe( - 'userinfo:read payment_methods.agentic aap:represent', - ); + expect(params.get('scope')).toBe('userinfo:read payment_methods.agentic'); // Returns immediately with verification URL and _next hint const output = parseJson(result.stdout) as Record[]; diff --git a/packages/cli/src/auth/__tests__/auth-resource.test.ts b/packages/cli/src/auth/__tests__/auth-resource.test.ts index 84d4f755..4628e2c4 100644 --- a/packages/cli/src/auth/__tests__/auth-resource.test.ts +++ b/packages/cli/src/auth/__tests__/auth-resource.test.ts @@ -72,9 +72,7 @@ describe('LinkAuthResource', () => { const body = mockFetch.mock.calls[0][1].body as string; const params = new URLSearchParams(body); - expect(params.get('scope')).toBe( - 'userinfo:read payment_methods.agentic aap:represent', - ); + expect(params.get('scope')).toBe('userinfo:read payment_methods.agentic'); }); it('passes a custom scope when provided', async () => { diff --git a/packages/cli/src/auth/__tests__/merge-access.test.ts b/packages/cli/src/auth/__tests__/merge-access.test.ts index d276acc5..1a527591 100644 --- a/packages/cli/src/auth/__tests__/merge-access.test.ts +++ b/packages/cli/src/auth/__tests__/merge-access.test.ts @@ -47,9 +47,7 @@ describe('computeMergedAccess', () => { existingAuthorizationDetails: [], }); - expect(merged.mergedScope).toBe( - 'userinfo:read payment_methods.agentic aap:represent', - ); + expect(merged.mergedScope).toBe('userinfo:read payment_methods.agentic'); }); it('unions source actions across requested and existing (keeps already-granted actions)', () => { diff --git a/packages/cli/src/auth/scopes.ts b/packages/cli/src/auth/scopes.ts index 4a2fc046..82ae7df3 100644 --- a/packages/cli/src/auth/scopes.ts +++ b/packages/cli/src/auth/scopes.ts @@ -1,7 +1,6 @@ export const DEFAULT_SCOPES = [ 'userinfo:read', 'payment_methods.agentic', - 'aap:represent', ] as const; export const DEFAULT_SCOPE = DEFAULT_SCOPES.join(' '); diff --git a/packages/cli/src/commands/attestations/schema.ts b/packages/cli/src/commands/attestations/schema.ts index b5768f63..34013a9a 100644 --- a/packages/cli/src/commands/attestations/schema.ts +++ b/packages/cli/src/commands/attestations/schema.ts @@ -15,6 +15,6 @@ export const requestOptions = z.object({ .string() .optional() .describe( - 'Bearer token for IDP authentication (needs the aap:represent scope). Defaults to the AAP_ACCESS_TOKEN env var, then the stored credentials from "link-cli auth login".', + 'Bearer token for IDP authentication. Defaults to the AAP_ACCESS_TOKEN env var, then the stored credentials from "link-cli auth login".', ), }); diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index ec794d32..f6a8c22a 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -71,7 +71,7 @@ Call `tools/list` to see all available MCP tools. - By default all output is in `toon` format. Pass `--format [json|md|yaml]` to change output format. - Some commands return a verification or approval URL. **These** must be presented to the user clearly for their action. - `--auth ` flag to store auth credentials in a specific file instead of the default location. `auth login` writes to this file; all other commands read from it. Example: `link-cli auth login --auth credentials.json` -- Agent Attestation Tokens are separate from the payment flow. When explicitly needed, request a pool with `link-cli attestations request --count <1-100> [--issuer ]`. +- Agent Attestation Tokens are separate from the payment flow. When explicitly needed, request a pool with `link-cli attestations request --count <1-100> [--issuer ]`; no AAP-specific OAuth scope is required. _Recommended_: Run `link-cli --llms` to understand all the available commands. The `--llms-full` output is the canonical reference for parameter names, types, and valid values. Pass `--schema` before invoking a command to understand its parameters and constraints. From 6cf94261f17acf43d946ecda4ea18fd8d3d2e969 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Thu, 27 Aug 2026 11:55:36 -0400 Subject: [PATCH 03/26] chore: drop create-payment-credential skill updates Keep AAP command guidance in README and CLAUDE.md so the published payment skill can change independently. Co-authored-by: Cursor Committed-By-Agent: cursor --- skills/create-payment-credential/SKILL.md | 1 - 1 file changed, 1 deletion(-) diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index f6a8c22a..f8afe8de 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -71,7 +71,6 @@ Call `tools/list` to see all available MCP tools. - By default all output is in `toon` format. Pass `--format [json|md|yaml]` to change output format. - Some commands return a verification or approval URL. **These** must be presented to the user clearly for their action. - `--auth ` flag to store auth credentials in a specific file instead of the default location. `auth login` writes to this file; all other commands read from it. Example: `link-cli auth login --auth credentials.json` -- Agent Attestation Tokens are separate from the payment flow. When explicitly needed, request a pool with `link-cli attestations request --count <1-100> [--issuer ]`; no AAP-specific OAuth scope is required. _Recommended_: Run `link-cli --llms` to understand all the available commands. The `--llms-full` output is the canonical reference for parameter names, types, and valid values. Pass `--schema` before invoking a command to understand its parameters and constraints. From 9557634916a4c00426cda255710a10449e66db72 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Thu, 27 Aug 2026 12:00:36 -0400 Subject: [PATCH 04/26] chore: drop the AAP acronym from attestation docs and CLI Keep issuer well-known paths as wire identifiers; describe the flow in terms of attestations and Privacy Pass instead. Co-authored-by: Cursor Committed-By-Agent: cursor --- CLAUDE.md | 8 ++++---- README.md | 2 +- packages/cli/src/commands/attestations/index.tsx | 3 +-- packages/cli/src/commands/attestations/schema.ts | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b50fa650..cd81a45a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,15 +109,15 @@ Key input field notes: - `onboard` — Guided setup: authenticates (skips if already logged in), checks payment methods (prompts to add one if missing, shows picker if multiple), shows app download QR code, then runs the full demo. Requires a TTY. -### attestations command (AAP) +### attestations command `attestations request --count [--issuer ] [--access-token ]` — mints Agent Attestation Tokens via the Privacy Pass Blind RSA protocol (token type `0x0002`, RFC 9578 / RFC 9577). Agent-only output. The SDK owns the protocol and API implementation in `packages/sdk/src/resources/attestations.ts` and `attestations-crypto.ts`; CLI schema and registration remain in `packages/cli/src/commands/attestations/`. - Discovery: `GET /.well-known/aap-issuer` → metadata, then `GET` its `token_keys` URL. The issuer and every discovered endpoint must use HTTPS on the same DNS origin; redirects and IP-literal hosts are rejected before credentials are sent. -- Tokens use the AAP stable challenge: fixed `issuer_name`, empty `redemption_context`, and empty `origin_info`. +- Tokens use a stable Privacy Pass challenge: fixed `issuer_name`, empty `redemption_context`, and empty `origin_info`. - Blind signatures are verified after unblinding before final tokens are returned. -- Server-side max batch is 100. Issuance does not require an AAP-specific OAuth scope. -- Auth: `--access-token`, else `AAP_ACCESS_TOKEN`, else stored CLI credentials. +- Server-side max batch is 100. Issuance does not require an additional OAuth scope. +- Auth: `--access-token`, else stored CLI credentials. ### serve command diff --git a/README.md b/README.md index 50ed7540..e9099e90 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ link-cli attestations request --count 10 link-cli attestations request --count 10 --issuer https://api.link.com ``` -`attestations request` fills an Agent Attestation Token pool using Privacy Pass Blind RSA. It accepts `--count` (1–100), an optional HTTPS `--issuer`, and an optional `--access-token`; no AAP-specific OAuth scope is required. Issuer discovery and issuance endpoints must remain on the issuer's HTTPS DNS origin; redirects and IP-literal hosts are rejected. +`attestations request` fills an Agent Attestation Token pool using Privacy Pass Blind RSA. It accepts `--count` (1–100), an optional HTTPS `--issuer`, and an optional `--access-token`. Issuer discovery and issuance endpoints must remain on the issuer's HTTPS DNS origin; redirects and IP-literal hosts are rejected. ### Spend request lifecycle diff --git a/packages/cli/src/commands/attestations/index.tsx b/packages/cli/src/commands/attestations/index.tsx index a81cd009..09ddb2ad 100644 --- a/packages/cli/src/commands/attestations/index.tsx +++ b/packages/cli/src/commands/attestations/index.tsx @@ -16,9 +16,8 @@ export function createAttestationsCli( outputPolicy: 'agent-only' as const, async run(c) { const { count, issuer, accessToken } = c.options; - const token = accessToken ?? process.env.AAP_ACCESS_TOKEN; - return createResource(token).request({ + return createResource(accessToken).request({ issuer, count, }); diff --git a/packages/cli/src/commands/attestations/schema.ts b/packages/cli/src/commands/attestations/schema.ts index 34013a9a..c0c17bdb 100644 --- a/packages/cli/src/commands/attestations/schema.ts +++ b/packages/cli/src/commands/attestations/schema.ts @@ -15,6 +15,6 @@ export const requestOptions = z.object({ .string() .optional() .describe( - 'Bearer token for IDP authentication. Defaults to the AAP_ACCESS_TOKEN env var, then the stored credentials from "link-cli auth login".', + 'Bearer token for IDP authentication. Defaults to the stored credentials from "link-cli auth login".', ), }); From bb626df5a289b1be3b262eacb76c546613d6c017 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Thu, 27 Aug 2026 16:37:43 -0400 Subject: [PATCH 05/26] chore: keep attestations unlisted unless LINK_IDENTITY_COMMANDS is set Incur can hide a command from MCP with mcp: false, but --help and --llms still advertise it. Gate registration so agents do not discover the command. Co-authored-by: Cursor Committed-By-Agent: cursor --- CLAUDE.md | 3 +++ README.md | 6 ++++-- packages/cli/src/cli.tsx | 12 +++++++++++- packages/cli/src/commands/attestations/index.tsx | 1 + 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cd81a45a..6fa8d51b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,6 +111,8 @@ Key input field notes: ### attestations command +Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENTITY_COMMANDS=1` (or `true`). Even when enabled, the command sets `mcp: false` so MCP clients do not see it. + `attestations request --count [--issuer ] [--access-token ]` — mints Agent Attestation Tokens via the Privacy Pass Blind RSA protocol (token type `0x0002`, RFC 9578 / RFC 9577). Agent-only output. The SDK owns the protocol and API implementation in `packages/sdk/src/resources/attestations.ts` and `attestations-crypto.ts`; CLI schema and registration remain in `packages/cli/src/commands/attestations/`. - Discovery: `GET /.well-known/aap-issuer` → metadata, then `GET` its `token_keys` URL. The issuer and every discovered endpoint must use HTTPS on the same DNS origin; redirects and IP-literal hosts are rejected before credentials are sent. @@ -160,3 +162,4 @@ JSON output mode (`--format json`) is **not** affected — `JSON.stringify` enco | `LINK_API_BASE_URL` | Override API base URL | | `LINK_AUTH_BASE_URL` | Override auth base URL | | `LINK_HTTP_PROXY` | Route all SDK requests through an HTTP proxy (requires `undici` installed) | +| `LINK_IDENTITY_COMMANDS` | When `1` or `true`, register the unlisted `attestations` command. Omitted from `--help`, `--llms`, and MCP otherwise. | diff --git a/README.md b/README.md index e9099e90..dcaf6dcc 100644 --- a/README.md +++ b/README.md @@ -277,9 +277,11 @@ All commands accept `--auth ` to store auth credentials in a specific file ### Agent attestation tokens +Unlisted command: set `LINK_IDENTITY_COMMANDS=1` to enable it. It is omitted from `--help`, `--llms`, and MCP tool lists otherwise. + ```bash -link-cli attestations request --count 10 -link-cli attestations request --count 10 --issuer https://api.link.com +LINK_IDENTITY_COMMANDS=1 link-cli attestations request --count 10 +LINK_IDENTITY_COMMANDS=1 link-cli attestations request --count 10 --issuer https://api.link.com ``` `attestations request` fills an Agent Attestation Token pool using Privacy Pass Blind RSA. It accepts `--count` (1–100), an optional HTTPS `--issuer`, and an optional `--access-token`. Issuer discovery and issuance endpoints must remain on the issuer's HTTPS DNS origin; redirects and IP-literal hosts are rejected. diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 99d6cc66..5a7a3d30 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -89,7 +89,17 @@ if (!isAgent && process.stdout.isTTY) { } } -cli.command(createAttestationsCli(() => factory.createAttestationsResource())); +const identityCommandsEnabled = + process.env.LINK_IDENTITY_COMMANDS === '1' || + process.env.LINK_IDENTITY_COMMANDS === 'true'; + +if (identityCommandsEnabled) { + cli.command( + createAttestationsCli((accessToken) => + factory.createAttestationsResource(accessToken), + ), + ); +} cli.command( createAuthCli(authRepo, getUpdateInfo, authStorage, envAccessToken), ); diff --git a/packages/cli/src/commands/attestations/index.tsx b/packages/cli/src/commands/attestations/index.tsx index 09ddb2ad..4caec901 100644 --- a/packages/cli/src/commands/attestations/index.tsx +++ b/packages/cli/src/commands/attestations/index.tsx @@ -13,6 +13,7 @@ export function createAttestationsCli( description: 'Request attestation tokens from an IDP using the Privacy Pass Blind RSA protocol (RFC 9578 type 0x0002). Returns tokens ready for use in Authorization: PrivateToken headers.', options: requestOptions, + mcp: false, outputPolicy: 'agent-only' as const, async run(c) { const { count, issuer, accessToken } = c.options; From 9a6f95503e4bac5b5172ac869ecc85791b1e2ca7 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Fri, 28 Aug 2026 09:59:02 -0400 Subject: [PATCH 06/26] feat: nest attestations under identity with plain-language copy Expose the command as `identity attestations request` and describe it as a privacy-preserving token that shows Link attests to your agent. Co-authored-by: Cursor Committed-By-Agent: cursor --- CLAUDE.md | 10 +++++----- README.md | 12 +++++++----- packages/cli/src/cli.tsx | 9 +++++---- .../cli/src/commands/attestations/index.tsx | 5 +++-- .../cli/src/commands/attestations/schema.ts | 6 +++--- packages/cli/src/commands/identity/index.tsx | 17 +++++++++++++++++ 6 files changed, 40 insertions(+), 19 deletions(-) create mode 100644 packages/cli/src/commands/identity/index.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 6fa8d51b..eb92eadd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ Commands in `packages/cli/src/cli.tsx` (incur framework). Each has two output mo - **Interactive** (default): Ink/React components from `packages/cli/src/commands/` - **JSON** (`--format json`): JSON to stdout, errors as JSON with `code` and `message` fields with exit code 1 -Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `attestations request`, `serve`. +Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `identity attestations request`, `serve`. The CLI also runs as an MCP server (`--mcp`) and serves skill files via `skills` subcommand, both provided by incur. @@ -109,14 +109,14 @@ Key input field notes: - `onboard` — Guided setup: authenticates (skips if already logged in), checks payment methods (prompts to add one if missing, shows picker if multiple), shows app download QR code, then runs the full demo. Requires a TTY. -### attestations command +### identity attestations command Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENTITY_COMMANDS=1` (or `true`). Even when enabled, the command sets `mcp: false` so MCP clients do not see it. -`attestations request --count [--issuer ] [--access-token ]` — mints Agent Attestation Tokens via the Privacy Pass Blind RSA protocol (token type `0x0002`, RFC 9578 / RFC 9577). Agent-only output. The SDK owns the protocol and API implementation in `packages/sdk/src/resources/attestations.ts` and `attestations-crypto.ts`; CLI schema and registration remain in `packages/cli/src/commands/attestations/`. +`identity attestations request --count [--issuer ] [--access-token ]` — gets privacy-preserving tokens that show Link attests to your agent. Agent-only output. The SDK owns issuance in `packages/sdk/src/resources/attestations.ts` and `attestations-crypto.ts`; CLI schema and registration remain in `packages/cli/src/commands/attestations/`, mounted under `packages/cli/src/commands/identity/`. - Discovery: `GET /.well-known/aap-issuer` → metadata, then `GET` its `token_keys` URL. The issuer and every discovered endpoint must use HTTPS on the same DNS origin; redirects and IP-literal hosts are rejected before credentials are sent. -- Tokens use a stable Privacy Pass challenge: fixed `issuer_name`, empty `redemption_context`, and empty `origin_info`. +- Tokens use a stable challenge: fixed `issuer_name`, empty `redemption_context`, and empty `origin_info`. - Blind signatures are verified after unblinding before final tokens are returned. - Server-side max batch is 100. Issuance does not require an additional OAuth scope. - Auth: `--access-token`, else stored CLI credentials. @@ -162,4 +162,4 @@ JSON output mode (`--format json`) is **not** affected — `JSON.stringify` enco | `LINK_API_BASE_URL` | Override API base URL | | `LINK_AUTH_BASE_URL` | Override auth base URL | | `LINK_HTTP_PROXY` | Route all SDK requests through an HTTP proxy (requires `undici` installed) | -| `LINK_IDENTITY_COMMANDS` | When `1` or `true`, register the unlisted `attestations` command. Omitted from `--help`, `--llms`, and MCP otherwise. | +| `LINK_IDENTITY_COMMANDS` | When `1` or `true`, register the unlisted `identity` command group. Omitted from `--help`, `--llms`, and MCP otherwise. | diff --git a/README.md b/README.md index dcaf6dcc..01f309de 100644 --- a/README.md +++ b/README.md @@ -275,16 +275,18 @@ Set `NO_UPDATE_NOTIFIER=1` to suppress update checks (for example, in CI). All commands accept `--auth ` to store auth credentials in a specific file instead of the default location. `auth login` writes to this file; all other commands read from it. Useful for running multiple sessions with separate identities. -### Agent attestation tokens +### Identity -Unlisted command: set `LINK_IDENTITY_COMMANDS=1` to enable it. It is omitted from `--help`, `--llms`, and MCP tool lists otherwise. +Unlisted commands: set `LINK_IDENTITY_COMMANDS=1` to enable them. They are omitted from `--help`, `--llms`, and MCP tool lists otherwise. + +Privacy-preserving tokens that show Link attests to your agent: ```bash -LINK_IDENTITY_COMMANDS=1 link-cli attestations request --count 10 -LINK_IDENTITY_COMMANDS=1 link-cli attestations request --count 10 --issuer https://api.link.com +LINK_IDENTITY_COMMANDS=1 link-cli identity attestations request --count 10 +LINK_IDENTITY_COMMANDS=1 link-cli identity attestations request --count 10 --issuer https://api.link.com ``` -`attestations request` fills an Agent Attestation Token pool using Privacy Pass Blind RSA. It accepts `--count` (1–100), an optional HTTPS `--issuer`, and an optional `--access-token`. Issuer discovery and issuance endpoints must remain on the issuer's HTTPS DNS origin; redirects and IP-literal hosts are rejected. +`identity attestations request` asks Link for a pool of those tokens (`--count` 1–100). You can pass an HTTPS `--issuer` and an `--access-token`; otherwise stored login credentials are used. Issuer discovery and issuance stay on the issuer's HTTPS DNS origin; redirects and IP-literal hosts are rejected. ### Spend request lifecycle diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 5a7a3d30..c84c0180 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -1,7 +1,7 @@ import { Cli } from 'incur'; import { type CliAuthStorage, Storage, storage } from './auth/storage'; -import { createAttestationsCli } from './commands/attestations'; import { createAuthCli } from './commands/auth'; +import { createIdentityCli } from './commands/identity'; import { createBalancesCli } from './commands/balances'; import { createDemoCli } from './commands/demo'; import { createMppCli } from './commands/mpp'; @@ -95,9 +95,10 @@ const identityCommandsEnabled = if (identityCommandsEnabled) { cli.command( - createAttestationsCli((accessToken) => - factory.createAttestationsResource(accessToken), - ), + createIdentityCli({ + createAttestationsResource: (accessToken) => + factory.createAttestationsResource(accessToken), + }), ); } cli.command( diff --git a/packages/cli/src/commands/attestations/index.tsx b/packages/cli/src/commands/attestations/index.tsx index 4caec901..27c85509 100644 --- a/packages/cli/src/commands/attestations/index.tsx +++ b/packages/cli/src/commands/attestations/index.tsx @@ -6,12 +6,13 @@ export function createAttestationsCli( createResource: (accessToken?: string) => IAttestationsResource, ) { const cli = Cli.create('attestations', { - description: 'Agent Attestation Token (AAT) commands', + description: + 'A privacy-preserving token that shows Link attests to your agent.', }); cli.command('request', { description: - 'Request attestation tokens from an IDP using the Privacy Pass Blind RSA protocol (RFC 9578 type 0x0002). Returns tokens ready for use in Authorization: PrivateToken headers.', + 'Get privacy-preserving tokens that show Link attests to your agent.', options: requestOptions, mcp: false, outputPolicy: 'agent-only' as const, diff --git a/packages/cli/src/commands/attestations/schema.ts b/packages/cli/src/commands/attestations/schema.ts index c0c17bdb..e2bba3a6 100644 --- a/packages/cli/src/commands/attestations/schema.ts +++ b/packages/cli/src/commands/attestations/schema.ts @@ -6,15 +6,15 @@ export const requestOptions = z.object({ .int() .positive() .max(100) - .describe('Number of attestation tokens to request'), + .describe('Number of tokens to request'), issuer: z .string() .default('https://api.link.com') - .describe('Issuer origin URL'), + .describe('Link origin that attests to your agent'), accessToken: z .string() .optional() .describe( - 'Bearer token for IDP authentication. Defaults to the stored credentials from "link-cli auth login".', + 'Access token. Defaults to the stored credentials from "link-cli auth login".', ), }); diff --git a/packages/cli/src/commands/identity/index.tsx b/packages/cli/src/commands/identity/index.tsx new file mode 100644 index 00000000..824acdb1 --- /dev/null +++ b/packages/cli/src/commands/identity/index.tsx @@ -0,0 +1,17 @@ +import type { IAttestationsResource } from '@stripe/link-sdk'; +import { Cli } from 'incur'; +import { createAttestationsCli } from '../attestations'; + +export function createIdentityCli(options: { + createAttestationsResource: ( + accessToken?: string, + ) => IAttestationsResource; +}) { + const cli = Cli.create('identity', { + description: + 'Privacy-preserving tokens that show Link attests to your agent.', + }); + + cli.command(createAttestationsCli(options.createAttestationsResource)); + return cli; +} From fde3ee2b5e4746a493781046e01fa06776857cd1 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Tue, 1 Sep 2026 15:28:33 -0400 Subject: [PATCH 07/26] fix: match Link generic batch attestation framing Committed-By-Agent: codex Co-authored-by: codex --- .../resources/__tests__/attestations.test.ts | 47 ++++++++ packages/sdk/src/resources/attestations.ts | 114 +++++++++++++----- 2 files changed, 132 insertions(+), 29 deletions(-) diff --git a/packages/sdk/src/resources/__tests__/attestations.test.ts b/packages/sdk/src/resources/__tests__/attestations.test.ts index 205eb2f1..6b5759f0 100644 --- a/packages/sdk/src/resources/__tests__/attestations.test.ts +++ b/packages/sdk/src/resources/__tests__/attestations.test.ts @@ -121,13 +121,60 @@ describe('AttestationsResource', () => { expect(getAccessToken).toHaveBeenNthCalledWith(2, { forceRefresh: true }); expect(fetchMock.mock.calls[2]?.[1]).toMatchObject({ headers: expect.objectContaining({ + Accept: 'application/private-token-generic-batch-response', Authorization: 'Bearer initial-token', + 'Content-Type': 'application/private-token-generic-batch-request', }), }); + const issuanceBody = fetchMock.mock.calls[2]?.[1]?.body; + expect(issuanceBody).toBeInstanceOf(ArrayBuffer); + const encodedRequest = Buffer.from(issuanceBody as ArrayBuffer); + expect(encodedRequest.subarray(0, 2)).toEqual(Buffer.from([0x40, 0x83])); + expect(encodedRequest.subarray(2, 4)).toEqual(Buffer.from([0x00, 0x02])); + expect(encodedRequest).toHaveLength(2 + 3 + 128); expect(fetchMock.mock.calls[3]?.[1]).toMatchObject({ headers: expect.objectContaining({ Authorization: 'Bearer refreshed-token', }), }); }); + + it('decodes GenericBatchTokenResponse optional entries', async () => { + const { publicKey } = generateKeyPairSync('rsa', { + modulusLength: 1024, + publicExponent: 0x10001, + }); + const tokenKey = publicKey + .export({ format: 'der', type: 'spki' }) + .toString('base64'); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith('/.well-known/aap-issuer')) { + return jsonResponse({ + issuer: 'https://issuer.example', + token_issuance_endpoint: 'https://issuer.example/issue', + token_keys: 'https://issuer.example/token-keys', + }); + } + if (url.endsWith('/token-keys')) { + return jsonResponse({ + 'token-keys': [{ 'token-type': 0x0002, 'token-key': tokenKey }], + }); + } + return new Response(Buffer.from([0x01, 0x00]), { + status: 200, + headers: { + 'Content-Type': 'application/private-token-generic-batch-response', + }, + }); + }); + const resource = new AttestationsResource({ + accessToken: 'secret', + fetch: fetchMock, + }); + + await expect( + resource.request({ issuer: 'https://issuer.example', count: 1 }), + ).rejects.toThrow('Issuer refused token request at index 0'); + }); }); diff --git a/packages/sdk/src/resources/attestations.ts b/packages/sdk/src/resources/attestations.ts index 66bb42dc..b141dde4 100644 --- a/packages/sdk/src/resources/attestations.ts +++ b/packages/sdk/src/resources/attestations.ts @@ -23,8 +23,10 @@ import type { import { z } from 'zod'; const TOKEN_TYPE_BLIND_RSA = 0x0002; -const CONTENT_TYPE_TOKEN_REQUEST = 'application/private-token-request'; -const CONTENT_TYPE_TOKEN_RESPONSE = 'application/private-token-response'; +const CONTENT_TYPE_TOKEN_REQUEST = + 'application/private-token-generic-batch-request'; +const CONTENT_TYPE_TOKEN_RESPONSE = + 'application/private-token-generic-batch-response'; const issuerMetadataSchema = z.looseObject({ issuer: z.string(), @@ -46,6 +48,51 @@ function base64ToBytes(value: string): Uint8Array { return new Uint8Array(Buffer.from(normalized, 'base64')); } +function minQuicVarintLength(value: number): 1 | 2 | 4 | 8 { + if (value < 2 ** 6) return 1; + if (value < 2 ** 14) return 2; + if (value < 2 ** 30) return 4; + return 8; +} + +function encodeQuicVarint(value: number): Buffer { + if (!Number.isSafeInteger(value) || value < 0 || value >= 2 ** 62) { + throw new Error(`Cannot encode ${value} as a QUIC variable-length integer`); + } + const length = minQuicVarintLength(value); + const encoded = Buffer.alloc(length); + let remaining = BigInt(value); + for (let index = length - 1; index >= 0; index--) { + encoded[index] = Number(remaining & 0xffn); + remaining >>= 8n; + } + encoded[0] = encoded[0]! | (Math.log2(length) << 6); + return encoded; +} + +function readQuicVarint( + body: Buffer, + offset = 0, +): { value: number; length: number } { + const first = body[offset]; + if (first === undefined) throw new Error('QUIC varint is absent'); + const length = 1 << (first >> 6); + if (body.length < offset + length) throw new Error('QUIC varint is truncated'); + + let value = BigInt(first & 0x3f); + for (let index = 1; index < length; index++) { + value = (value << 8n) | BigInt(body[offset + index]!); + } + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error('QUIC varint exceeds the JavaScript safe integer range'); + } + const numeric = Number(value); + if (minQuicVarintLength(numeric) !== length) { + throw new Error('QUIC varint is not minimally encoded'); + } + return { value: numeric, length }; +} + function encodeBatchTokenRequest( blindedMessages: Uint8Array[], truncatedTokenKeyId: number, @@ -59,9 +106,7 @@ function encodeBatchTokenRequest( }); const vector = Buffer.concat(entries); - const prefix = Buffer.alloc(2); - prefix.writeUInt16BE(vector.length, 0); - return Buffer.concat([prefix, vector]); + return Buffer.concat([encodeQuicVarint(vector.length), vector]); } function decodeBatchTokenResponse( @@ -69,39 +114,50 @@ function decodeBatchTokenResponse( elementSize: number, expectedCount: number, ): string[] { - if (body.length < 2) { + const prefix = readQuicVarint(body); + const vector = body.subarray(prefix.length); + if (vector.length !== prefix.value) { throw new Error( - `BatchTokenResponse too short: ${body.length} bytes (expected at least 2)`, + `BatchTokenResponse length prefix says ${prefix.value} bytes but ${vector.length} bytes follow`, ); } + if (vector.length === 0) throw new Error('BatchTokenResponse vector is empty'); - const vectorLength = body.readUInt16BE(0); - const vector = body.subarray(2); - if (vector.length !== vectorLength) { - throw new Error( - `BatchTokenResponse length prefix says ${vectorLength} bytes but ${vector.length} bytes follow`, - ); - } - if (vectorLength === 0 || vectorLength % elementSize !== 0) { - throw new Error( - `BatchTokenResponse vector of ${vectorLength} bytes is not a multiple of the ${elementSize}-byte element size`, + const signatures: string[] = []; + let offset = 0; + while (offset < vector.length) { + const present = vector[offset++]; + if (present === 0) { + throw new Error( + `Issuer refused token request at index ${signatures.length}`, + ); + } + if (present !== 1) { + throw new Error(`Invalid OptionalTokenResponse presence byte ${present}`); + } + if (offset + 2 + elementSize > vector.length) { + throw new Error('Present GenericTokenResponse is truncated'); + } + const tokenType = vector.readUInt16BE(offset); + offset += 2; + if (tokenType !== TOKEN_TYPE_BLIND_RSA) { + throw new Error( + `GenericTokenResponse has unsupported token type 0x${tokenType.toString(16).padStart(4, '0')}`, + ); + } + signatures.push( + base64urlEncode( + new Uint8Array(vector.subarray(offset, offset + elementSize)), + ), ); + offset += elementSize; } - - const count = vectorLength / elementSize; - if (count !== expectedCount) { + if (signatures.length !== expectedCount) { throw new Error( - `Issuer returned ${count} blind signatures for ${expectedCount} token requests`, + `Issuer returned ${signatures.length} token responses for ${expectedCount} token requests`, ); } - - return Array.from({ length: count }, (_, index) => - base64urlEncode( - new Uint8Array( - vector.subarray(index * elementSize, (index + 1) * elementSize), - ), - ), - ); + return signatures; } function parseIssuerOrigin(issuer: string): URL { From 29e5625673b2cbe1fa2fd97a158b9eb37f629b6a Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Mon, 24 Aug 2026 21:27:08 -0400 Subject: [PATCH 08/26] feat: add holder-bound identity credential wallet Provision SD-JWT credentials through the issuer's discovered credential endpoint while keeping private holder keys local to the CLI. Co-authored-by: Cursor Committed-By-Agent: cursor --- CLAUDE.md | 12 +- README.md | 9 + packages/cli/src/cli.tsx | 6 + .../src/commands/credentials/holder-key.ts | 102 ++++++++++++ .../cli/src/commands/credentials/index.tsx | 31 ++++ .../cli/src/commands/credentials/issue.ts | 69 ++++++++ .../cli/src/commands/credentials/schema.ts | 24 +++ .../utils/__tests__/resource-factory.test.ts | 4 + packages/cli/src/utils/resource-factory.ts | 18 ++ packages/sdk/src/client.ts | 4 + packages/sdk/src/index.ts | 1 + .../resources/__tests__/credentials.test.ts | 157 ++++++++++++++++++ .../src/resources/__tests__/factory.test.ts | 3 + packages/sdk/src/resources/aap-issuer.ts | 52 ++++++ packages/sdk/src/resources/attestations.ts | 55 +----- packages/sdk/src/resources/credentials.ts | 154 +++++++++++++++++ packages/sdk/src/resources/interfaces.ts | 18 ++ 17 files changed, 667 insertions(+), 52 deletions(-) create mode 100644 packages/cli/src/commands/credentials/holder-key.ts create mode 100644 packages/cli/src/commands/credentials/index.tsx create mode 100644 packages/cli/src/commands/credentials/issue.ts create mode 100644 packages/cli/src/commands/credentials/schema.ts create mode 100644 packages/sdk/src/resources/__tests__/credentials.test.ts create mode 100644 packages/sdk/src/resources/aap-issuer.ts create mode 100644 packages/sdk/src/resources/credentials.ts diff --git a/CLAUDE.md b/CLAUDE.md index eb92eadd..6f49236c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,7 @@ node packages/cli/dist/cli.js Defined in `packages/sdk/src/resources/interfaces.ts`: - `IAttestationsResource` — Privacy Pass Blind RSA token issuance +- `ICredentialsResource` — holder-bound SD-JWT-VC issuance - `ISpendRequestResource` — CRUD + request-approval for spend requests The SDK only accepts credentials. Device authorization, refresh-token @@ -51,7 +52,7 @@ Commands in `packages/cli/src/cli.tsx` (incur framework). Each has two output mo - **Interactive** (default): Ink/React components from `packages/cli/src/commands/` - **JSON** (`--format json`): JSON to stdout, errors as JSON with `code` and `message` fields with exit code 1 -Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `identity attestations request`, `serve`. +Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `identity attestations request`, `identity credentials get`, `serve`. The CLI also runs as an MCP server (`--mcp`) and serves skill files via `skills` subcommand, both provided by incur. @@ -121,6 +122,15 @@ Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENT - Server-side max batch is 100. Issuance does not require an additional OAuth scope. - Auth: `--access-token`, else stored CLI credentials. +### credentials command (AAP) + +`credentials issue [--key-file ] [--key-type ed25519|p256] [--access-token ]` — provisions a short-lived holder-bound SD-JWT-VC with the user's identity claims. Agent-only output. The SDK discovers and calls `credential_endpoint`; the CLI owns holder-key persistence, disclosure decoding, schema, and command registration. + +- Discovery uses `GET /.well-known/aap-issuer`, where `` is `LINK_API_BASE_URL` or `https://api.link.com`. The metadata `issuer` and `credential_endpoint` must remain on that HTTPS DNS origin. +- `POST ` sends `{"cnf":{"jwk":}}`. Only the public Ed25519 or P-256 members are sent. +- The private holder key is persisted at `--key-file` (default `~/.link/holder-key.jwk`, mode 0600) and reused across runs. +- Requires `aap:represent`, `userinfo:read`, and `payment_methods.agentic`. + ### serve command - `serve [--port ] [--host ]` — HTTP server that exposes the CLI's MCP endpoint. Implemented in `packages/cli/src/commands/serve/index.ts`. The handler forwards to `rootCli.fetch()` (incur), but is a **privilege boundary**: `requireAuth` only proves the CLI *owner* is authenticated, not that the HTTP caller is authorized. diff --git a/README.md b/README.md index 01f309de..10aa9617 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,15 @@ LINK_IDENTITY_COMMANDS=1 link-cli identity attestations request --count 10 --iss `identity attestations request` asks Link for a pool of those tokens (`--count` 1–100). You can pass an HTTPS `--issuer` and an `--access-token`; otherwise stored login credentials are used. Issuer discovery and issuance stay on the issuer's HTTPS DNS origin; redirects and IP-literal hosts are rejected. +### Identity credential wallet + +```bash +link-cli credentials issue +link-cli credentials issue --key-file ~/.link/holder-key.jwk --key-type ed25519 +``` + +`credentials issue` provisions a short-lived SD-JWT-VC bound to a locally persisted holder key. The SDK discovers the issuer's `credential_endpoint` through `/.well-known/aap-issuer`; it never assumes a fixed credential path. + ### Spend request lifecycle A spend request moves through: **create** → **request approval** → **approved** (with credentials). diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index c84c0180..7cef513d 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -3,6 +3,7 @@ import { type CliAuthStorage, Storage, storage } from './auth/storage'; import { createAuthCli } from './commands/auth'; import { createIdentityCli } from './commands/identity'; import { createBalancesCli } from './commands/balances'; +import { createCredentialsCli } from './commands/credentials'; import { createDemoCli } from './commands/demo'; import { createMppCli } from './commands/mpp'; import { createOnboardCli } from './commands/onboard'; @@ -100,6 +101,11 @@ if (identityCommandsEnabled) { factory.createAttestationsResource(accessToken), }), ); + cli.command( + createCredentialsCli((accessToken) => + factory.createCredentialsResource(accessToken), + ), + ); } cli.command( createAuthCli(authRepo, getUpdateInfo, authStorage, envAccessToken), diff --git a/packages/cli/src/commands/credentials/holder-key.ts b/packages/cli/src/commands/credentials/holder-key.ts new file mode 100644 index 00000000..e6b3cb8c --- /dev/null +++ b/packages/cli/src/commands/credentials/holder-key.ts @@ -0,0 +1,102 @@ +import { + type KeyObject, + createPrivateKey, + generateKeyPairSync, +} from 'node:crypto'; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import type { HolderPublicJwk } from '@stripe/link-sdk'; + +/** + * Holder key types accepted by the issuer in `cnf.jwk`: + * Ed25519 (EdDSA, mandatory to implement) and P-256 (ES256, optional). + */ +export type HolderKeyType = 'ed25519' | 'p256'; + +export interface HolderKey { + type: HolderKeyType; + privateKey: KeyObject; + publicJwk: HolderPublicJwk; + /** True when the key was generated by this call rather than read from disk. */ + created: boolean; +} + +interface StoredHolderKey { + type: HolderKeyType; + private_jwk: Record; +} + +function toPublicJwk(privateKey: KeyObject): HolderPublicJwk { + const jwk = privateKey.export({ format: 'jwk' }) as Record; + + // Strip everything but the members the issuer allows — notably `d`, the + // private scalar, which must never leave the local key file. + if (jwk.kty === 'OKP') { + return { kty: 'OKP', crv: 'Ed25519', x: jwk.x }; + } + return { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y }; +} + +function generateHolderKey(type: HolderKeyType): KeyObject { + if (type === 'ed25519') { + return generateKeyPairSync('ed25519').privateKey; + } + return generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey; +} + +/** + * Loads the holder key from `path`, generating and persisting one if the file + * does not exist yet. The credential is bound to this key, so it must be + * reusable across runs to be presentable later: the file is written with 0600 + * permissions and holds the private JWK. + */ +export function loadOrCreateHolderKey( + path: string, + type: HolderKeyType, +): HolderKey { + let stored: StoredHolderKey | undefined; + try { + stored = JSON.parse(readFileSync(path, 'utf8')) as StoredHolderKey; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error( + `Failed to read holder key at ${path}: ${(error as Error).message}`, + ); + } + } + + if (stored) { + const privateKey = createPrivateKey({ + key: stored.private_jwk as never, + format: 'jwk', + }); + return { + type: stored.type, + privateKey, + publicJwk: toPublicJwk(privateKey), + created: false, + }; + } + + const privateKey = generateHolderKey(type); + const payload: StoredHolderKey = { + type, + private_jwk: privateKey.export({ format: 'jwk' }) as Record< + string, + unknown + >, + }; + + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 }); + // writeFileSync's mode is ignored when the file already exists, so set it + // explicitly — this file holds a private key. + chmodSync(path, 0o600); + + return { + type, + privateKey, + publicJwk: toPublicJwk(privateKey), + created: true, + }; +} diff --git a/packages/cli/src/commands/credentials/index.tsx b/packages/cli/src/commands/credentials/index.tsx new file mode 100644 index 00000000..cf82bc53 --- /dev/null +++ b/packages/cli/src/commands/credentials/index.tsx @@ -0,0 +1,31 @@ +import type { ICredentialsResource } from '@stripe/link-sdk'; +import { Cli } from 'incur'; +import { issueOptions } from './schema'; + +export function createCredentialsCli( + createResource: (accessToken?: string) => ICredentialsResource, +) { + const cli = Cli.create('credentials', { + description: 'Agent identity credential (SD-JWT-VC) commands', + }); + + cli.command('issue', { + description: + "Issue a short-lived SD-JWT-VC holding the user's identity claims (email, phone_number, given_name, family_name), bound to a local holder key. Present selective disclosures from it to merchants.", + options: issueOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + const { keyFile, keyType, accessToken } = c.options; + const token = accessToken ?? process.env.AAP_ACCESS_TOKEN; + + const { issueCredential } = await import('./issue'); + return issueCredential({ + resource: createResource(token), + keyFile, + keyType, + }); + }, + }); + + return cli; +} diff --git a/packages/cli/src/commands/credentials/issue.ts b/packages/cli/src/commands/credentials/issue.ts new file mode 100644 index 00000000..dc0bb5a7 --- /dev/null +++ b/packages/cli/src/commands/credentials/issue.ts @@ -0,0 +1,69 @@ +import type { HolderPublicJwk, ICredentialsResource } from '@stripe/link-sdk'; +import { type HolderKeyType, loadOrCreateHolderKey } from './holder-key'; + +export interface CredentialIssueResult { + credential: string; + issuer: string; + expires_at: string; + /** Claim names and values recovered from the credential's disclosures. */ + claims: Record; + holder_key: { + path: string; + created: boolean; + jwk: HolderPublicJwk; + }; +} + +function decodeJsonSegment(segment: string): unknown { + return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')); +} + +/** + * Recovers the disclosed claims from a compact SD-JWT-VC: + * + * ~~...~ + * + * Each disclosure is base64url(JSON [salt, claim_name, claim_value]). + */ +function decodeDisclosedClaims(credential: string): Record { + const [, ...disclosures] = credential.split('~'); + const claims: Record = {}; + + for (const disclosure of disclosures) { + if (!disclosure) { + // Trailing separator on a credential with no key-binding JWT. + continue; + } + const parsed = decodeJsonSegment(disclosure); + if (Array.isArray(parsed) && parsed.length === 3) { + claims[String(parsed[1])] = parsed[2]; + } + } + + return claims; +} + +export async function issueCredential(options: { + resource: ICredentialsResource; + keyFile: string; + keyType: HolderKeyType; +}): Promise { + const { resource, keyFile, keyType } = options; + + const holderKey = loadOrCreateHolderKey(keyFile, keyType); + const response = await resource.issue({ + cnf: { jwk: holderKey.publicJwk }, + }); + + return { + credential: response.credential, + issuer: response.issuer, + expires_at: response.expires_at, + claims: decodeDisclosedClaims(response.credential), + holder_key: { + path: keyFile, + created: holderKey.created, + jwk: holderKey.publicJwk, + }, + }; +} diff --git a/packages/cli/src/commands/credentials/schema.ts b/packages/cli/src/commands/credentials/schema.ts new file mode 100644 index 00000000..02287992 --- /dev/null +++ b/packages/cli/src/commands/credentials/schema.ts @@ -0,0 +1,24 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { z } from 'incur'; + +export const issueOptions = z.object({ + keyFile: z + .string() + .default(join(homedir(), '.link', 'holder-key.jwk')) + .describe( + 'Path to the holder private key (JWK). Generated with 0600 permissions if it does not exist. The credential is bound to this key, so reuse the same file to present it later.', + ), + keyType: z + .enum(['ed25519', 'p256']) + .default('ed25519') + .describe( + 'Holder key type to generate when --key-file does not exist yet: ed25519 (EdDSA) or p256 (ES256). Ignored when the file already exists.', + ), + accessToken: z + .string() + .optional() + .describe( + 'Bearer token for the issuer (needs the aap:represent, userinfo:read and payment_methods.agentic scopes). Defaults to the stored credentials from "link-cli auth login".', + ), +}); diff --git a/packages/cli/src/utils/__tests__/resource-factory.test.ts b/packages/cli/src/utils/__tests__/resource-factory.test.ts index 9b704821..4d56a67c 100644 --- a/packages/cli/src/utils/__tests__/resource-factory.test.ts +++ b/packages/cli/src/utils/__tests__/resource-factory.test.ts @@ -28,6 +28,9 @@ describe('ResourceFactory', () => { expect(factory.createAttestationsResource()).toBe( factory.createAttestationsResource(), ); + expect(factory.createCredentialsResource()).toBe( + factory.createCredentialsResource(), + ); expect(factory.createSpendRequestResource()).toBe( factory.createSpendRequestResource(), ); @@ -42,6 +45,7 @@ describe('ResourceFactory', () => { ); expect(factory.createAuthResource()).toBeInstanceOf(LinkAuthResource); expect(factory.createAttestationsResource().request).toBeTypeOf('function'); + expect(factory.createCredentialsResource().issue).toBeTypeOf('function'); expect(factory.createSpendRequestResource().create).toBeTypeOf('function'); expect(factory.createPaymentMethodsResource().list).toBeTypeOf('function'); expect(factory.createBalancesResource().list).toBeTypeOf('function'); diff --git a/packages/cli/src/utils/resource-factory.ts b/packages/cli/src/utils/resource-factory.ts index 7848de13..c2aa8ce9 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -2,6 +2,7 @@ import { type AccessTokenProvider, type IAttestationsResource, type IBalancesResource, + type ICredentialsResource, type IPaymentMethodsResource, type IReportResource, type IShippingAddressResource, @@ -114,6 +115,7 @@ export class ResourceFactory { private accessTokenProvider?: ReturnType; private sdkClient?: Link; private attestationsResource?: IAttestationsResource; + private credentialsResource?: ICredentialsResource; private spendRequestResource?: ISpendRequestResource; private paymentMethodsResource?: IPaymentMethodsResource; private shippingAddressResource?: IShippingAddressResource; @@ -244,6 +246,22 @@ export class ResourceFactory { return this.attestationsResource; } + createCredentialsResource(accessToken?: string): ICredentialsResource { + if (accessToken !== undefined) { + return sanitizeResource( + new Link(this.createSdkOptions({ accessToken })).credentials, + ); + } + if (this.credentialsResource) { + return this.credentialsResource; + } + + this.credentialsResource = sanitizeResource( + this.createSdkClient().credentials, + ); + return this.credentialsResource; + } + createSpendRequestResource(): ISpendRequestResource { if (this.spendRequestResource) { return this.spendRequestResource; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index f87b1b9d..5c808531 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -1,9 +1,11 @@ import type { LinkOptions } from '@/config'; import { AttestationsResource } from '@/resources/attestations'; import { BalancesResource } from '@/resources/balances'; +import { CredentialsResource } from '@/resources/credentials'; import type { IAttestationsResource, IBalancesResource, + ICredentialsResource, IPaymentMethodsResource, IReportResource, IShippingAddressResource, @@ -24,6 +26,7 @@ import { WebBotAuthResource } from '@/resources/web-bot-auth'; export class Link { readonly attestations: IAttestationsResource; + readonly credentials: ICredentialsResource; readonly spendRequests: ISpendRequestResource; readonly paymentMethods: IPaymentMethodsResource; readonly shippingAddresses: IShippingAddressResource; @@ -36,6 +39,7 @@ export class Link { constructor(options: LinkOptions) { this.attestations = new AttestationsResource(options); + this.credentials = new CredentialsResource(options); this.spendRequests = new SpendRequestResource(options); this.paymentMethods = new PaymentMethodsResource(options); this.shippingAddresses = new ShippingAddressResource(options); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 4ae61eb0..33f5d29f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -10,4 +10,5 @@ export { export * from './types/index'; export * from './resources/interfaces'; export * from './resources/attestations'; +export * from './resources/credentials'; export { getDuplicateSpendRequest } from './resources/spend-request'; diff --git a/packages/sdk/src/resources/__tests__/credentials.test.ts b/packages/sdk/src/resources/__tests__/credentials.test.ts new file mode 100644 index 00000000..f6d6863d --- /dev/null +++ b/packages/sdk/src/resources/__tests__/credentials.test.ts @@ -0,0 +1,157 @@ +import { LinkResponseError } from '@/errors'; +import { CredentialsResource } from '@/resources/credentials'; +import { describe, expect, it, vi } from 'vitest'; + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const PUBLIC_JWK = { + kty: 'OKP' as const, + crv: 'Ed25519' as const, + x: 'public-key', +}; + +describe('CredentialsResource', () => { + it('issues through the discovered credential endpoint', async () => { + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/.well-known/aap-issuer')) { + return jsonResponse({ + issuer: 'https://issuer.example', + credential_endpoint: 'https://issuer.example/aap-issuer/credential', + }); + } + expect(url).toBe('https://issuer.example/aap-issuer/credential'); + expect(init?.headers).toMatchObject({ + Authorization: 'Bearer access-token', + }); + expect(JSON.parse(String(init?.body))).toEqual({ + cnf: { jwk: PUBLIC_JWK }, + }); + return jsonResponse({ + credential: 'issuer-jwt~', + issuer: 'https://issuer.example', + expires_at: '2026-08-25T00:00:00Z', + }); + }, + ); + const resource = new CredentialsResource({ + apiBaseUrl: 'https://issuer.example', + accessToken: 'access-token', + fetch: fetchMock, + }); + + await expect( + resource.issue({ cnf: { jwk: PUBLIC_JWK } }), + ).resolves.toMatchObject({ + credential: 'issuer-jwt~', + issuer: 'https://issuer.example', + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('rejects an off-origin credential endpoint before authentication', async () => { + const getAccessToken = vi.fn(async () => 'secret'); + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + jsonResponse({ + issuer: 'https://issuer.example', + credential_endpoint: 'https://attacker.example/credential', + }), + ); + const resource = new CredentialsResource({ + apiBaseUrl: 'https://issuer.example', + getAccessToken, + fetch: fetchMock, + }); + + await expect(resource.issue({ cnf: { jwk: PUBLIC_JWK } })).rejects.toThrow( + 'credential_endpoint must be an HTTPS URL', + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getAccessToken).not.toHaveBeenCalled(); + }); + + it('refuses issuer metadata redirects', async () => { + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response('', { + status: 302, + headers: { Location: 'https://attacker.example/metadata' }, + }), + ); + const resource = new CredentialsResource({ + apiBaseUrl: 'https://issuer.example', + accessToken: 'access-token', + fetch: fetchMock, + }); + + await expect(resource.issue({ cnf: { jwk: PUBLIC_JWK } })).rejects.toThrow( + 'Refused redirect', + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('wraps malformed credential responses in LinkResponseError', async () => { + const fetchMock = vi.fn( + async (input: RequestInfo | URL, _init?: RequestInit) => + String(input).endsWith('/.well-known/aap-issuer') + ? jsonResponse({ + issuer: 'https://issuer.example', + credential_endpoint: 'https://issuer.example/credential', + }) + : jsonResponse({ credential: 42 }), + ); + const resource = new CredentialsResource({ + apiBaseUrl: 'https://issuer.example', + accessToken: 'access-token', + fetch: fetchMock, + }); + + await expect( + resource.issue({ cnf: { jwk: PUBLIC_JWK } }), + ).rejects.toBeInstanceOf(LinkResponseError); + }); + + it('refreshes LinkOptions authentication after a credential 401', async () => { + const fetchMock = vi.fn( + async (input: RequestInfo | URL, _init?: RequestInit) => + String(input).endsWith('/.well-known/aap-issuer') + ? jsonResponse({ + issuer: 'https://issuer.example', + credential_endpoint: 'https://issuer.example/credential', + }) + : jsonResponse({ error: 'unauthorized' }, 401), + ); + const getAccessToken = vi.fn( + ({ forceRefresh }: { forceRefresh?: boolean } = {}) => + forceRefresh ? 'refreshed-token' : 'initial-token', + ); + const resource = new CredentialsResource({ + apiBaseUrl: 'https://issuer.example', + getAccessToken, + fetch: fetchMock, + }); + + await expect(resource.issue({ cnf: { jwk: PUBLIC_JWK } })).rejects.toThrow( + 'Failed to issue credential (401)', + ); + expect(getAccessToken).toHaveBeenNthCalledWith(1, undefined); + expect(getAccessToken).toHaveBeenNthCalledWith(2, { forceRefresh: true }); + expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ + headers: expect.objectContaining({ + Authorization: 'Bearer initial-token', + }), + }); + expect(fetchMock.mock.calls[2]?.[1]).toMatchObject({ + headers: expect.objectContaining({ + Authorization: 'Bearer refreshed-token', + }), + }); + }); +}); diff --git a/packages/sdk/src/resources/__tests__/factory.test.ts b/packages/sdk/src/resources/__tests__/factory.test.ts index dc1a2fed..b9dd2546 100644 --- a/packages/sdk/src/resources/__tests__/factory.test.ts +++ b/packages/sdk/src/resources/__tests__/factory.test.ts @@ -1,5 +1,6 @@ import Link from '@/client'; import { AttestationsResource } from '@/resources/attestations'; +import { CredentialsResource } from '@/resources/credentials'; import { PaymentMethodsResource } from '@/resources/payment-methods'; import { ReportResource } from '@/resources/report'; import { SpendRequestResource } from '@/resources/spend-request'; @@ -16,6 +17,7 @@ describe('Link', () => { }); expect(client.attestations).toBeInstanceOf(AttestationsResource); + expect(client.credentials).toBeInstanceOf(CredentialsResource); expect(client.spendRequests).toBeInstanceOf(SpendRequestResource); expect(client.paymentMethods).toBeInstanceOf(PaymentMethodsResource); expect(client.transactions).toBeInstanceOf(TransactionsResource); @@ -25,6 +27,7 @@ describe('Link', () => { expect(client.spendRequests.update).toBeTypeOf('function'); expect(client.spendRequests.retrieve).toBeTypeOf('function'); expect(client.attestations.request).toBeTypeOf('function'); + expect(client.credentials.issue).toBeTypeOf('function'); expect(client.paymentMethods.list).toBeTypeOf('function'); expect(client.transactions.list).toBeTypeOf('function'); }); diff --git a/packages/sdk/src/resources/aap-issuer.ts b/packages/sdk/src/resources/aap-issuer.ts new file mode 100644 index 00000000..ade08137 --- /dev/null +++ b/packages/sdk/src/resources/aap-issuer.ts @@ -0,0 +1,52 @@ +import { isIP } from 'node:net'; +import { LinkConfigurationError } from '@/errors'; + +export function parseIssuerOrigin(issuer: string): URL { + let url: URL; + try { + url = new URL(issuer); + } catch (error) { + throw new LinkConfigurationError(`Invalid issuer URL: ${issuer}`, { + cause: error, + }); + } + const hostname = url.hostname.replace(/^\[|\]$/g, ''); + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash || + isIP(hostname) !== 0 + ) { + throw new LinkConfigurationError( + 'Issuer must be an HTTPS origin with a DNS hostname', + ); + } + return url; +} + +export function requireIssuerEndpoint( + value: string, + issuerOrigin: string, + field: string, +): string { + let url: URL; + try { + url = new URL(value); + } catch (error) { + throw new TypeError(`${field} is not a valid URL`, { cause: error }); + } + const hostname = url.hostname.replace(/^\[|\]$/g, ''); + if ( + url.protocol !== 'https:' || + url.origin !== issuerOrigin || + url.username || + url.password || + isIP(hostname) !== 0 + ) { + throw new TypeError(`${field} must be an HTTPS URL on the issuer origin`); + } + return url.href; +} diff --git a/packages/sdk/src/resources/attestations.ts b/packages/sdk/src/resources/attestations.ts index b141dde4..e0787144 100644 --- a/packages/sdk/src/resources/attestations.ts +++ b/packages/sdk/src/resources/attestations.ts @@ -1,5 +1,4 @@ import { createHash } from 'node:crypto'; -import { isIP } from 'node:net'; import type { LinkOptions } from '@/config'; import { LinkApiError, @@ -7,6 +6,10 @@ import { LinkResponseError, LinkTransportError, } from '@/errors'; +import { + parseIssuerOrigin, + requireIssuerEndpoint, +} from '@/resources/aap-issuer'; import { type FinalToken, base64urlEncode, @@ -160,56 +163,6 @@ function decodeBatchTokenResponse( return signatures; } -function parseIssuerOrigin(issuer: string): URL { - let url: URL; - try { - url = new URL(issuer); - } catch (error) { - throw new LinkConfigurationError(`Invalid issuer URL: ${issuer}`, { - cause: error, - }); - } - const hostname = url.hostname.replace(/^\[|\]$/g, ''); - if ( - url.protocol !== 'https:' || - url.username || - url.password || - url.pathname !== '/' || - url.search || - url.hash || - isIP(hostname) !== 0 - ) { - throw new LinkConfigurationError( - 'Issuer must be an HTTPS origin with a DNS hostname', - ); - } - return url; -} - -function requireIssuerEndpoint( - value: string, - issuerOrigin: string, - field: string, -): string { - let url: URL; - try { - url = new URL(value); - } catch (error) { - throw new TypeError(`${field} is not a valid URL`, { cause: error }); - } - const hostname = url.hostname.replace(/^\[|\]$/g, ''); - if ( - url.protocol !== 'https:' || - url.origin !== issuerOrigin || - url.username || - url.password || - isIP(hostname) !== 0 - ) { - throw new TypeError(`${field} must be an HTTPS URL on the issuer origin`); - } - return url.href; -} - export class AttestationsResource extends BaseResource implements IAttestationsResource diff --git a/packages/sdk/src/resources/credentials.ts b/packages/sdk/src/resources/credentials.ts new file mode 100644 index 00000000..facfd0af --- /dev/null +++ b/packages/sdk/src/resources/credentials.ts @@ -0,0 +1,154 @@ +import type { LinkOptions } from '@/config'; +import { LinkApiError, LinkResponseError, LinkTransportError } from '@/errors'; +import { + parseIssuerOrigin, + requireIssuerEndpoint, +} from '@/resources/aap-issuer'; +import { BaseResource } from '@/resources/base'; +import type { + CredentialIssueParams, + CredentialIssueResponse, + ICredentialsResource, +} from '@/resources/interfaces'; +import { z } from 'zod'; + +const credentialIssuerMetadataSchema = z.looseObject({ + issuer: z.string(), + credential_endpoint: z.string(), +}); + +const credentialIssueResponseSchema = z.looseObject({ + credential: z.string(), + issuer: z.string(), + expires_at: z.string(), +}); + +export class CredentialsResource + extends BaseResource + implements ICredentialsResource +{ + private readonly issuerUrl: URL; + + constructor(options: LinkOptions) { + super(options, ''); + this.issuerUrl = parseIssuerOrigin(this.endpoint); + } + + private async discoverCredentialEndpoint(): Promise { + const metadataUrl = new URL('/.well-known/aap-issuer', this.issuerUrl).href; + let response: Response; + try { + response = await this.fetchImpl(metadataUrl, { redirect: 'manual' }); + } catch (error) { + throw new LinkTransportError(`Request failed: GET ${metadataUrl}`, { + cause: error, + }); + } + + const rawBody = await response.text(); + if (response.status >= 300 && response.status < 400) { + throw new LinkApiError( + `Refused redirect while fetching issuer metadata (${response.status})`, + { status: response.status, rawBody }, + ); + } + + let data: unknown = null; + try { + data = JSON.parse(rawBody); + } catch (error) { + if (response.ok) { + throw new LinkResponseError('fetch issuer metadata', response.status, { + cause: error, + }); + } + } + if (!response.ok) { + this.throwApiError( + 'fetch issuer metadata', + response.status, + data, + rawBody, + ); + } + + const metadata = this.parseResponse( + 'parse issuer metadata', + response.status, + () => credentialIssuerMetadataSchema.parse(data), + ); + return this.parseResponse( + 'validate issuer metadata', + response.status, + () => { + const metadataIssuerUrl = parseIssuerOrigin(metadata.issuer); + if (metadataIssuerUrl.origin !== this.issuerUrl.origin) { + throw new TypeError( + 'issuer metadata identifier must match the discovery origin', + ); + } + return requireIssuerEndpoint( + metadata.credential_endpoint, + this.issuerUrl.origin, + 'credential_endpoint', + ); + }, + ); + } + + async issue(params: CredentialIssueParams): Promise { + const endpoint = await this.discoverCredentialEndpoint(); + const send = async (forceRefresh = false): Promise => { + const token = await this.getAccessToken( + forceRefresh ? { forceRefresh: true } : undefined, + ); + try { + return await this.fetchImpl(endpoint, { + method: 'POST', + redirect: 'manual', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(params), + }); + } catch (error) { + throw new LinkTransportError(`Request failed: POST ${endpoint}`, { + cause: error, + }); + } + }; + + let response = await send(); + if (response.status === 401 && this.canRefreshAccessToken) { + response = await send(true); + } + + const rawBody = await response.text(); + if (response.status >= 300 && response.status < 400) { + throw new LinkApiError( + `Refused redirect while issuing credential (${response.status})`, + { status: response.status, rawBody }, + ); + } + + let data: unknown = null; + try { + data = JSON.parse(rawBody); + } catch (error) { + if (response.ok) { + throw new LinkResponseError('issue credential', response.status, { + cause: error, + }); + } + } + if (!response.ok) { + this.throwApiError('issue credential', response.status, data, rawBody); + } + + return this.parseResponse('issue credential', response.status, () => + credentialIssueResponseSchema.parse(data), + ); + } +} diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index 0416564a..cd16f9b9 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -39,6 +39,24 @@ export interface IAttestationsResource { request(params: AttestationRequestParams): Promise; } +export type HolderPublicJwk = + | { kty: 'OKP'; crv: 'Ed25519'; x: string } + | { kty: 'EC'; crv: 'P-256'; x: string; y: string }; + +export interface CredentialIssueParams { + cnf: { jwk: HolderPublicJwk }; +} + +export interface CredentialIssueResponse { + credential: string; + issuer: string; + expires_at: string; +} + +export interface ICredentialsResource { + issue(params: CredentialIssueParams): Promise; +} + export interface CreateSpendRequestParams { payment_details?: string; credential_type?: CredentialType; From 71d87928b4e636a7b255cb7a3ec700b23669f0e3 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Tue, 25 Aug 2026 19:00:08 -0400 Subject: [PATCH 09/26] fix: defer credential issuer validation Avoid breaking unrelated SDK resources when local HTTP API overrides are configured; enforce AAP issuer constraints only when credential issuance begins. Co-authored-by: Cursor Committed-By-Agent: cursor --- packages/sdk/src/resources/credentials.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/sdk/src/resources/credentials.ts b/packages/sdk/src/resources/credentials.ts index facfd0af..f8c44690 100644 --- a/packages/sdk/src/resources/credentials.ts +++ b/packages/sdk/src/resources/credentials.ts @@ -27,15 +27,13 @@ export class CredentialsResource extends BaseResource implements ICredentialsResource { - private readonly issuerUrl: URL; - constructor(options: LinkOptions) { super(options, ''); - this.issuerUrl = parseIssuerOrigin(this.endpoint); } private async discoverCredentialEndpoint(): Promise { - const metadataUrl = new URL('/.well-known/aap-issuer', this.issuerUrl).href; + const issuerUrl = parseIssuerOrigin(this.endpoint); + const metadataUrl = new URL('/.well-known/aap-issuer', issuerUrl).href; let response: Response; try { response = await this.fetchImpl(metadataUrl, { redirect: 'manual' }); @@ -82,14 +80,14 @@ export class CredentialsResource response.status, () => { const metadataIssuerUrl = parseIssuerOrigin(metadata.issuer); - if (metadataIssuerUrl.origin !== this.issuerUrl.origin) { + if (metadataIssuerUrl.origin !== issuerUrl.origin) { throw new TypeError( 'issuer metadata identifier must match the discovery origin', ); } return requireIssuerEndpoint( metadata.credential_endpoint, - this.issuerUrl.origin, + issuerUrl.origin, 'credential_endpoint', ); }, From b2f901904903e1a2a3c83dfa95cafcf3ba7ee0c9 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Tue, 25 Aug 2026 19:04:47 -0400 Subject: [PATCH 10/26] fix: keep credential token overrides explicit Use AAP_ACCESS_TOKEN only for attestation minting while credential issuance follows its documented flag-or-stored-session authentication path. Co-authored-by: Cursor Committed-By-Agent: cursor --- packages/cli/src/commands/credentials/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/commands/credentials/index.tsx b/packages/cli/src/commands/credentials/index.tsx index cf82bc53..50060843 100644 --- a/packages/cli/src/commands/credentials/index.tsx +++ b/packages/cli/src/commands/credentials/index.tsx @@ -16,11 +16,10 @@ export function createCredentialsCli( outputPolicy: 'agent-only' as const, async run(c) { const { keyFile, keyType, accessToken } = c.options; - const token = accessToken ?? process.env.AAP_ACCESS_TOKEN; const { issueCredential } = await import('./issue'); return issueCredential({ - resource: createResource(token), + resource: createResource(accessToken), keyFile, keyType, }); From 8250b404dcda59f1f21713fd4ab80f8ec6363e5e Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Wed, 26 Aug 2026 18:17:57 -0400 Subject: [PATCH 11/26] fix: remove obsolete credential scope guidance Credential issuance no longer requires aap:represent, so document only the remaining user and payment-method scopes. Co-authored-by: Cursor Committed-By-Agent: cursor --- CLAUDE.md | 2 +- README.md | 2 +- packages/cli/src/commands/credentials/schema.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6f49236c..d21debf2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,7 +129,7 @@ Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENT - Discovery uses `GET /.well-known/aap-issuer`, where `` is `LINK_API_BASE_URL` or `https://api.link.com`. The metadata `issuer` and `credential_endpoint` must remain on that HTTPS DNS origin. - `POST ` sends `{"cnf":{"jwk":}}`. Only the public Ed25519 or P-256 members are sent. - The private holder key is persisted at `--key-file` (default `~/.link/holder-key.jwk`, mode 0600) and reused across runs. -- Requires `aap:represent`, `userinfo:read`, and `payment_methods.agentic`. +- Requires `userinfo:read` and `payment_methods.agentic`; no AAP-specific OAuth scope is required. ### serve command diff --git a/README.md b/README.md index 10aa9617..e8049a03 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ link-cli credentials issue link-cli credentials issue --key-file ~/.link/holder-key.jwk --key-type ed25519 ``` -`credentials issue` provisions a short-lived SD-JWT-VC bound to a locally persisted holder key. The SDK discovers the issuer's `credential_endpoint` through `/.well-known/aap-issuer`; it never assumes a fixed credential path. +`credentials issue` provisions a short-lived SD-JWT-VC bound to a locally persisted holder key without requiring an AAP-specific OAuth scope. The SDK discovers the issuer's `credential_endpoint` through `/.well-known/aap-issuer`; it never assumes a fixed credential path. ### Spend request lifecycle diff --git a/packages/cli/src/commands/credentials/schema.ts b/packages/cli/src/commands/credentials/schema.ts index 02287992..11787ab4 100644 --- a/packages/cli/src/commands/credentials/schema.ts +++ b/packages/cli/src/commands/credentials/schema.ts @@ -19,6 +19,6 @@ export const issueOptions = z.object({ .string() .optional() .describe( - 'Bearer token for the issuer (needs the aap:represent, userinfo:read and payment_methods.agentic scopes). Defaults to the stored credentials from "link-cli auth login".', + 'Bearer token for the issuer (needs the userinfo:read and payment_methods.agentic scopes). Defaults to the stored credentials from "link-cli auth login".', ), }); From ed8b88d44f9d9efabd00b5f840feed48c8c27bf7 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Thu, 27 Aug 2026 12:01:26 -0400 Subject: [PATCH 12/26] chore: drop the AAP acronym from credential-wallet docs and names Rename issuer-origin helpers and describe discovery without the protocol acronym. Keep the well-known metadata path as a wire identifier. Co-authored-by: Cursor Committed-By-Agent: cursor --- CLAUDE.md | 4 ++-- README.md | 2 +- packages/sdk/src/resources/__tests__/credentials.test.ts | 4 ++-- packages/sdk/src/resources/attestations.ts | 2 +- packages/sdk/src/resources/credentials.ts | 2 +- .../sdk/src/resources/{aap-issuer.ts => issuer-origin.ts} | 0 6 files changed, 7 insertions(+), 7 deletions(-) rename packages/sdk/src/resources/{aap-issuer.ts => issuer-origin.ts} (100%) diff --git a/CLAUDE.md b/CLAUDE.md index d21debf2..195f5d60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,14 +122,14 @@ Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENT - Server-side max batch is 100. Issuance does not require an additional OAuth scope. - Auth: `--access-token`, else stored CLI credentials. -### credentials command (AAP) +### credentials command `credentials issue [--key-file ] [--key-type ed25519|p256] [--access-token ]` — provisions a short-lived holder-bound SD-JWT-VC with the user's identity claims. Agent-only output. The SDK discovers and calls `credential_endpoint`; the CLI owns holder-key persistence, disclosure decoding, schema, and command registration. - Discovery uses `GET /.well-known/aap-issuer`, where `` is `LINK_API_BASE_URL` or `https://api.link.com`. The metadata `issuer` and `credential_endpoint` must remain on that HTTPS DNS origin. - `POST ` sends `{"cnf":{"jwk":}}`. Only the public Ed25519 or P-256 members are sent. - The private holder key is persisted at `--key-file` (default `~/.link/holder-key.jwk`, mode 0600) and reused across runs. -- Requires `userinfo:read` and `payment_methods.agentic`; no AAP-specific OAuth scope is required. +- Requires `userinfo:read` and `payment_methods.agentic`; no additional OAuth scope is required. ### serve command diff --git a/README.md b/README.md index e8049a03..789ca77a 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ link-cli credentials issue link-cli credentials issue --key-file ~/.link/holder-key.jwk --key-type ed25519 ``` -`credentials issue` provisions a short-lived SD-JWT-VC bound to a locally persisted holder key without requiring an AAP-specific OAuth scope. The SDK discovers the issuer's `credential_endpoint` through `/.well-known/aap-issuer`; it never assumes a fixed credential path. +`credentials issue` provisions a short-lived SD-JWT-VC bound to a locally persisted holder key. The SDK discovers the issuer's `credential_endpoint` from issuer metadata; it never assumes a fixed credential path. ### Spend request lifecycle diff --git a/packages/sdk/src/resources/__tests__/credentials.test.ts b/packages/sdk/src/resources/__tests__/credentials.test.ts index f6d6863d..58ca0132 100644 --- a/packages/sdk/src/resources/__tests__/credentials.test.ts +++ b/packages/sdk/src/resources/__tests__/credentials.test.ts @@ -23,10 +23,10 @@ describe('CredentialsResource', () => { if (url.endsWith('/.well-known/aap-issuer')) { return jsonResponse({ issuer: 'https://issuer.example', - credential_endpoint: 'https://issuer.example/aap-issuer/credential', + credential_endpoint: 'https://issuer.example/credential', }); } - expect(url).toBe('https://issuer.example/aap-issuer/credential'); + expect(url).toBe('https://issuer.example/credential'); expect(init?.headers).toMatchObject({ Authorization: 'Bearer access-token', }); diff --git a/packages/sdk/src/resources/attestations.ts b/packages/sdk/src/resources/attestations.ts index e0787144..d0e7c69e 100644 --- a/packages/sdk/src/resources/attestations.ts +++ b/packages/sdk/src/resources/attestations.ts @@ -9,7 +9,7 @@ import { import { parseIssuerOrigin, requireIssuerEndpoint, -} from '@/resources/aap-issuer'; +} from '@/resources/issuer-origin'; import { type FinalToken, base64urlEncode, diff --git a/packages/sdk/src/resources/credentials.ts b/packages/sdk/src/resources/credentials.ts index f8c44690..8e936f05 100644 --- a/packages/sdk/src/resources/credentials.ts +++ b/packages/sdk/src/resources/credentials.ts @@ -3,7 +3,7 @@ import { LinkApiError, LinkResponseError, LinkTransportError } from '@/errors'; import { parseIssuerOrigin, requireIssuerEndpoint, -} from '@/resources/aap-issuer'; +} from '@/resources/issuer-origin'; import { BaseResource } from '@/resources/base'; import type { CredentialIssueParams, diff --git a/packages/sdk/src/resources/aap-issuer.ts b/packages/sdk/src/resources/issuer-origin.ts similarity index 100% rename from packages/sdk/src/resources/aap-issuer.ts rename to packages/sdk/src/resources/issuer-origin.ts From e52a5385ed1dba112b0c1698022f3fab38a443f4 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Thu, 27 Aug 2026 16:38:23 -0400 Subject: [PATCH 13/26] chore: keep credentials unlisted unless LINK_IDENTITY_COMMANDS is set Same discovery gate as attestations: register only when opted in, and keep the command out of MCP tool lists. Co-authored-by: Cursor Committed-By-Agent: cursor --- CLAUDE.md | 2 ++ README.md | 6 ++++-- packages/cli/src/commands/credentials/index.tsx | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 195f5d60..2c509feb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,8 @@ Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENT ### credentials command +Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENTITY_COMMANDS=1` (or `true`). Even when enabled, the command sets `mcp: false` so MCP clients do not see it. + `credentials issue [--key-file ] [--key-type ed25519|p256] [--access-token ]` — provisions a short-lived holder-bound SD-JWT-VC with the user's identity claims. Agent-only output. The SDK discovers and calls `credential_endpoint`; the CLI owns holder-key persistence, disclosure decoding, schema, and command registration. - Discovery uses `GET /.well-known/aap-issuer`, where `` is `LINK_API_BASE_URL` or `https://api.link.com`. The metadata `issuer` and `credential_endpoint` must remain on that HTTPS DNS origin. diff --git a/README.md b/README.md index 789ca77a..25ee0a50 100644 --- a/README.md +++ b/README.md @@ -290,9 +290,11 @@ LINK_IDENTITY_COMMANDS=1 link-cli identity attestations request --count 10 --iss ### Identity credential wallet +Unlisted command: set `LINK_IDENTITY_COMMANDS=1` to enable it. It is omitted from `--help`, `--llms`, and MCP tool lists otherwise. + ```bash -link-cli credentials issue -link-cli credentials issue --key-file ~/.link/holder-key.jwk --key-type ed25519 +LINK_IDENTITY_COMMANDS=1 link-cli credentials issue +LINK_IDENTITY_COMMANDS=1 link-cli credentials issue --key-file ~/.link/holder-key.jwk --key-type ed25519 ``` `credentials issue` provisions a short-lived SD-JWT-VC bound to a locally persisted holder key. The SDK discovers the issuer's `credential_endpoint` from issuer metadata; it never assumes a fixed credential path. diff --git a/packages/cli/src/commands/credentials/index.tsx b/packages/cli/src/commands/credentials/index.tsx index 50060843..59063eff 100644 --- a/packages/cli/src/commands/credentials/index.tsx +++ b/packages/cli/src/commands/credentials/index.tsx @@ -13,6 +13,7 @@ export function createCredentialsCli( description: "Issue a short-lived SD-JWT-VC holding the user's identity claims (email, phone_number, given_name, family_name), bound to a local holder key. Present selective disclosures from it to merchants.", options: issueOptions, + mcp: false, outputPolicy: 'agent-only' as const, async run(c) { const { keyFile, keyType, accessToken } = c.options; From fa118e7fa52ebce76262286e29e1b56f671c2664 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Fri, 28 Aug 2026 10:00:58 -0400 Subject: [PATCH 14/26] feat: nest credentials under identity as credentials get Use `identity credentials get` and describe it as signed user info from Link rather than SD-JWT terminology. Co-authored-by: Cursor Committed-By-Agent: cursor --- CLAUDE.md | 8 ++++---- README.md | 10 ++++------ packages/cli/src/cli.tsx | 10 +++------- packages/cli/src/commands/credentials/index.tsx | 11 ++++++----- packages/cli/src/commands/credentials/schema.ts | 8 ++++---- packages/cli/src/commands/identity/index.tsx | 13 ++++++++++--- 6 files changed, 31 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2c509feb..3f0475af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ node packages/cli/dist/cli.js Defined in `packages/sdk/src/resources/interfaces.ts`: - `IAttestationsResource` — Privacy Pass Blind RSA token issuance -- `ICredentialsResource` — holder-bound SD-JWT-VC issuance +- `ICredentialsResource` — signed user info issuance - `ISpendRequestResource` — CRUD + request-approval for spend requests The SDK only accepts credentials. Device authorization, refresh-token @@ -122,15 +122,15 @@ Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENT - Server-side max batch is 100. Issuance does not require an additional OAuth scope. - Auth: `--access-token`, else stored CLI credentials. -### credentials command +### identity credentials command Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENTITY_COMMANDS=1` (or `true`). Even when enabled, the command sets `mcp: false` so MCP clients do not see it. -`credentials issue [--key-file ] [--key-type ed25519|p256] [--access-token ]` — provisions a short-lived holder-bound SD-JWT-VC with the user's identity claims. Agent-only output. The SDK discovers and calls `credential_endpoint`; the CLI owns holder-key persistence, disclosure decoding, schema, and command registration. +`identity credentials get [--key-file ] [--key-type ed25519|p256] [--access-token ]` — gets signed user info proving it comes from Link (a wallet of claims such as name, email, and phone). Agent-only output. The SDK discovers and calls `credential_endpoint`; the CLI owns local key persistence, claim decoding, schema, and command registration under `packages/cli/src/commands/identity/`. - Discovery uses `GET /.well-known/aap-issuer`, where `` is `LINK_API_BASE_URL` or `https://api.link.com`. The metadata `issuer` and `credential_endpoint` must remain on that HTTPS DNS origin. - `POST ` sends `{"cnf":{"jwk":}}`. Only the public Ed25519 or P-256 members are sent. -- The private holder key is persisted at `--key-file` (default `~/.link/holder-key.jwk`, mode 0600) and reused across runs. +- The private key is persisted at `--key-file` (default `~/.link/holder-key.jwk`, mode 0600) and reused across runs. - Requires `userinfo:read` and `payment_methods.agentic`; no additional OAuth scope is required. ### serve command diff --git a/README.md b/README.md index 25ee0a50..0aded54e 100644 --- a/README.md +++ b/README.md @@ -288,16 +288,14 @@ LINK_IDENTITY_COMMANDS=1 link-cli identity attestations request --count 10 --iss `identity attestations request` asks Link for a pool of those tokens (`--count` 1–100). You can pass an HTTPS `--issuer` and an `--access-token`; otherwise stored login credentials are used. Issuer discovery and issuance stay on the issuer's HTTPS DNS origin; redirects and IP-literal hosts are rejected. -### Identity credential wallet - -Unlisted command: set `LINK_IDENTITY_COMMANDS=1` to enable it. It is omitted from `--help`, `--llms`, and MCP tool lists otherwise. +User info that has been signed, proving it comes from Link: ```bash -LINK_IDENTITY_COMMANDS=1 link-cli credentials issue -LINK_IDENTITY_COMMANDS=1 link-cli credentials issue --key-file ~/.link/holder-key.jwk --key-type ed25519 +LINK_IDENTITY_COMMANDS=1 link-cli identity credentials get +LINK_IDENTITY_COMMANDS=1 link-cli identity credentials get --key-file ~/.link/holder-key.jwk --key-type ed25519 ``` -`credentials issue` provisions a short-lived SD-JWT-VC bound to a locally persisted holder key. The SDK discovers the issuer's `credential_endpoint` from issuer metadata; it never assumes a fixed credential path. +`identity credentials get` fetches that signed user info and keeps a local key so you can present the same wallet of claims later. Link tells the CLI where to request it; there is no fixed path to hard-code. ### Spend request lifecycle diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 7cef513d..5995814c 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -1,10 +1,9 @@ import { Cli } from 'incur'; import { type CliAuthStorage, Storage, storage } from './auth/storage'; import { createAuthCli } from './commands/auth'; -import { createIdentityCli } from './commands/identity'; import { createBalancesCli } from './commands/balances'; -import { createCredentialsCli } from './commands/credentials'; import { createDemoCli } from './commands/demo'; +import { createIdentityCli } from './commands/identity'; import { createMppCli } from './commands/mpp'; import { createOnboardCli } from './commands/onboard'; import { createPaymentMethodsCli } from './commands/payment-methods'; @@ -99,13 +98,10 @@ if (identityCommandsEnabled) { createIdentityCli({ createAttestationsResource: (accessToken) => factory.createAttestationsResource(accessToken), + createCredentialsResource: (accessToken) => + factory.createCredentialsResource(accessToken), }), ); - cli.command( - createCredentialsCli((accessToken) => - factory.createCredentialsResource(accessToken), - ), - ); } cli.command( createAuthCli(authRepo, getUpdateInfo, authStorage, envAccessToken), diff --git a/packages/cli/src/commands/credentials/index.tsx b/packages/cli/src/commands/credentials/index.tsx index 59063eff..b040495b 100644 --- a/packages/cli/src/commands/credentials/index.tsx +++ b/packages/cli/src/commands/credentials/index.tsx @@ -1,18 +1,19 @@ import type { ICredentialsResource } from '@stripe/link-sdk'; import { Cli } from 'incur'; -import { issueOptions } from './schema'; +import { getOptions } from './schema'; export function createCredentialsCli( createResource: (accessToken?: string) => ICredentialsResource, ) { const cli = Cli.create('credentials', { - description: 'Agent identity credential (SD-JWT-VC) commands', + description: + 'User info that has been signed, proving it comes from Link.', }); - cli.command('issue', { + cli.command('get', { description: - "Issue a short-lived SD-JWT-VC holding the user's identity claims (email, phone_number, given_name, family_name), bound to a local holder key. Present selective disclosures from it to merchants.", - options: issueOptions, + 'Get signed user info proving it comes from Link. Includes a wallet of claims such as name, email, and phone that you can present later.', + options: getOptions, mcp: false, outputPolicy: 'agent-only' as const, async run(c) { diff --git a/packages/cli/src/commands/credentials/schema.ts b/packages/cli/src/commands/credentials/schema.ts index 11787ab4..52dffb79 100644 --- a/packages/cli/src/commands/credentials/schema.ts +++ b/packages/cli/src/commands/credentials/schema.ts @@ -2,23 +2,23 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { z } from 'incur'; -export const issueOptions = z.object({ +export const getOptions = z.object({ keyFile: z .string() .default(join(homedir(), '.link', 'holder-key.jwk')) .describe( - 'Path to the holder private key (JWK). Generated with 0600 permissions if it does not exist. The credential is bound to this key, so reuse the same file to present it later.', + 'Path to a local key file. Created if missing. Reuse the same file when you present this user info later.', ), keyType: z .enum(['ed25519', 'p256']) .default('ed25519') .describe( - 'Holder key type to generate when --key-file does not exist yet: ed25519 (EdDSA) or p256 (ES256). Ignored when the file already exists.', + 'Key type to generate when --key-file does not exist yet. Ignored when the file already exists.', ), accessToken: z .string() .optional() .describe( - 'Bearer token for the issuer (needs the userinfo:read and payment_methods.agentic scopes). Defaults to the stored credentials from "link-cli auth login".', + 'Access token. Defaults to the stored credentials from "link-cli auth login".', ), }); diff --git a/packages/cli/src/commands/identity/index.tsx b/packages/cli/src/commands/identity/index.tsx index 824acdb1..409e4316 100644 --- a/packages/cli/src/commands/identity/index.tsx +++ b/packages/cli/src/commands/identity/index.tsx @@ -1,17 +1,24 @@ -import type { IAttestationsResource } from '@stripe/link-sdk'; +import type { + IAttestationsResource, + ICredentialsResource, +} from '@stripe/link-sdk'; import { Cli } from 'incur'; import { createAttestationsCli } from '../attestations'; +import { createCredentialsCli } from '../credentials'; export function createIdentityCli(options: { createAttestationsResource: ( accessToken?: string, ) => IAttestationsResource; + createCredentialsResource: ( + accessToken?: string, + ) => ICredentialsResource; }) { const cli = Cli.create('identity', { - description: - 'Privacy-preserving tokens that show Link attests to your agent.', + description: 'Prove your agent and user identity with Link.', }); cli.command(createAttestationsCli(options.createAttestationsResource)); + cli.command(createCredentialsCli(options.createCredentialsResource)); return cli; } From d2a6b2b893a130458831664a27678b372224aa8f Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Tue, 1 Sep 2026 20:11:31 -0400 Subject: [PATCH 15/26] refactor: centralize the default identity holder key Committed-By-Agent: codex Co-authored-by: codex --- packages/cli/src/commands/credentials/holder-key.ts | 9 ++++++++- packages/cli/src/commands/credentials/schema.ts | 5 ++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/credentials/holder-key.ts b/packages/cli/src/commands/credentials/holder-key.ts index e6b3cb8c..0e09fa5b 100644 --- a/packages/cli/src/commands/credentials/holder-key.ts +++ b/packages/cli/src/commands/credentials/holder-key.ts @@ -4,9 +4,16 @@ import { generateKeyPairSync, } from 'node:crypto'; import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; import type { HolderPublicJwk } from '@stripe/link-sdk'; +export const DEFAULT_HOLDER_KEY_PATH = join( + homedir(), + '.link', + 'holder-key.jwk', +); + /** * Holder key types accepted by the issuer in `cnf.jwk`: * Ed25519 (EdDSA, mandatory to implement) and P-256 (ES256, optional). diff --git a/packages/cli/src/commands/credentials/schema.ts b/packages/cli/src/commands/credentials/schema.ts index 52dffb79..6629e677 100644 --- a/packages/cli/src/commands/credentials/schema.ts +++ b/packages/cli/src/commands/credentials/schema.ts @@ -1,11 +1,10 @@ -import { homedir } from 'node:os'; -import { join } from 'node:path'; import { z } from 'incur'; +import { DEFAULT_HOLDER_KEY_PATH } from './holder-key'; export const getOptions = z.object({ keyFile: z .string() - .default(join(homedir(), '.link', 'holder-key.jwk')) + .default(DEFAULT_HOLDER_KEY_PATH) .describe( 'Path to a local key file. Created if missing. Reuse the same file when you present this user info later.', ), From 30d932f227fe2316b3a23ef9271a208857273c23 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Mon, 24 Aug 2026 21:37:43 -0400 Subject: [PATCH 16/26] feat: add signed identity-aware HTTP requests Satisfy validated AAP claims challenges with selective SD-JWT disclosure and a fail-closed request-specific Web Bot Auth signature. Co-authored-by: Cursor Committed-By-Agent: cursor --- CLAUDE.md | 14 +- README.md | 9 + packages/cli/src/cli.tsx | 9 + packages/cli/src/commands/request/index.tsx | 263 +++++++++++ .../cli/src/commands/request/present.test.ts | 143 ++++++ packages/cli/src/commands/request/present.ts | 415 ++++++++++++++++++ packages/cli/src/commands/request/schema.ts | 44 ++ .../resources/__tests__/web-bot-auth.test.ts | 80 ++++ packages/sdk/src/resources/base.ts | 2 + packages/sdk/src/resources/interfaces.ts | 8 + packages/sdk/src/resources/web-bot-auth.ts | 147 ++++++- 11 files changed, 1115 insertions(+), 19 deletions(-) create mode 100644 packages/cli/src/commands/request/index.tsx create mode 100644 packages/cli/src/commands/request/present.test.ts create mode 100644 packages/cli/src/commands/request/present.ts create mode 100644 packages/cli/src/commands/request/schema.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3f0475af..a4921915 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ Commands in `packages/cli/src/cli.tsx` (incur framework). Each has two output mo - **Interactive** (default): Ink/React components from `packages/cli/src/commands/` - **JSON** (`--format json`): JSON to stdout, errors as JSON with `code` and `message` fields with exit code 1 -Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `identity attestations request`, `identity credentials get`, `serve`. +Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `identity attestations request`, `identity credentials get`, `identity request`, `serve`. The CLI also runs as an MCP server (`--mcp`) and serves skill files via `skills` subcommand, both provided by incur. @@ -133,6 +133,16 @@ Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENT - The private key is persisted at `--key-file` (default `~/.link/holder-key.jwk`, mode 0600) and reused across runs. - Requires `userinfo:read` and `payment_methods.agentic`; no additional OAuth scope is required. +### request command (AAP) + +`request [--claims "a,b,c"] [-X ] [-d ] [-H
]... [--key-file ] [--key-type ed25519|p256]` — satisfies a pre-provisioned identity-claims challenge and retries with a holder-bound presentation. HTTPS is required except for loopback development. + +- Recognizes a challenge only when status is 401, `WWW-Authenticate` includes `Identity-Presentation`, content type is `application/problem+json`, and the body type is `urn:aap:claims-required`. +- Requires the challenge `aud` to exactly equal the request origin, supports `dc+sd-jwt`, and honors `trusted_issuers`. +- Supports string and nested claims path pointers. `sd_hash` uses the credential's `_sd_alg` (default `sha-256`). +- The retry uses a request-specific Web Bot Auth HTTP Message Signature covering method, authority, path, `Signature-Agent`, `Identity-Presentation`, any `Authorization`, and `Content-Digest` when a body is present. +- Redirects are not followed, preventing identity presentations or authorization credentials from crossing origins. + ### serve command - `serve [--port ] [--host ]` — HTTP server that exposes the CLI's MCP endpoint. Implemented in `packages/cli/src/commands/serve/index.ts`. The handler forwards to `rootCli.fetch()` (incur), but is a **privilege boundary**: `requireAuth` only proves the CLI *owner* is authenticated, not that the HTTP caller is authorized. @@ -160,7 +170,7 @@ Server-returned strings can contain ANSI escape sequences or control characters - **SDK-resource data** — sanitized automatically at the `sanitizeResource()` proxy boundary in `packages/cli/src/utils/resource-factory.ts`. All server data flowing through SDK resources (spend-request, payment-methods, sources, etc.) is `sanitizeDeep()`'d before reaching components or the incur formatter, in every output format. - **Commands using `useAsyncAction` hook** — sanitized automatically. The hook calls `sanitizeDeep()` on all returned data before it reaches components. - **Commands with manual state management** (e.g. `create.tsx`, `retrieve.tsx`, `request-approval.tsx`, `mpp/pay.tsx`) — must call `sanitizeDeep()` on API responses before calling `setRequest()`/`setState()`. -- **Attacker-controlled data that does NOT flow through an SDK resource** — must be sanitized at its own parse boundary. `mpp pay` sanitizes the HTTP response in `readPayResult()` (`pay.tsx`); `mpp decode` sanitizes the parsed `WWW-Authenticate` challenge in `decodeStripeChallenge()` (`decode.ts`). These bypass the resource factory, so the return value of the parse/fetch helper is the chokepoint — sanitizing there covers both the interactive Ink render and the agent (toon/yaml/md) output at once. +- **Attacker-controlled data that does NOT flow through an SDK resource** — must be sanitized at its own parse boundary. `mpp pay` sanitizes the HTTP response in `readPayResult()` (`pay.tsx`); `mpp decode` sanitizes the parsed `WWW-Authenticate` challenge in `decodeStripeChallenge()` (`decode.ts`); `request` sanitizes claims challenges in `parseClaimsChallenge()` and merchant response bodies in `parseBody()`. These bypass the resource factory, so the return value of the parse/fetch helper is the chokepoint — sanitizing there covers both the interactive Ink render and the agent (toon/yaml/md) output at once. JSON output mode (`--format json`) is **not** affected — `JSON.stringify` encodes escape sequences as Unicode literals. ## Environment Variables diff --git a/README.md b/README.md index 0aded54e..51710f6a 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,15 @@ LINK_IDENTITY_COMMANDS=1 link-cli identity credentials get --key-file ~/.link/ho `identity credentials get` fetches that signed user info and keeps a local key so you can present the same wallet of claims later. Link tells the CLI where to request it; there is no fixed path to hard-code. +### Identity-aware HTTP requests + +```bash +link-cli request https://merchant.example/checkout +link-cli request https://merchant.example/checkout --claims email,given_name +``` + +When the HTTPS origin returns an AAP `Identity-Presentation` challenge, `request` provisions a holder-bound credential, selects only the requested claims, validates the audience/format/trusted issuer, and retries with both the presentation and a request-specific Web Bot Auth signature. Redirects are not followed. + ### Spend request lifecycle A spend request moves through: **create** → **request approval** → **approved** (with credentials). diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 5995814c..bc31ee0a 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -8,6 +8,7 @@ import { createMppCli } from './commands/mpp'; import { createOnboardCli } from './commands/onboard'; import { createPaymentMethodsCli } from './commands/payment-methods'; import { createReportCli } from './commands/report'; +import { createRequestCli } from './commands/request'; import { createServeCli } from './commands/serve'; import { createShippingAddressCli } from './commands/shipping-address'; import { createSourcesCli } from './commands/sources'; @@ -103,6 +104,14 @@ if (identityCommandsEnabled) { }), ); } +cli.command( + createRequestCli( + () => factory.createCredentialsResource(), + () => factory.createWebBotAuthResource(), + authStorage, + envAccessToken, + ), +); cli.command( createAuthCli(authRepo, getUpdateInfo, authStorage, envAccessToken), ); diff --git a/packages/cli/src/commands/request/index.tsx b/packages/cli/src/commands/request/index.tsx new file mode 100644 index 00000000..534a66c1 --- /dev/null +++ b/packages/cli/src/commands/request/index.tsx @@ -0,0 +1,263 @@ +import type { + ICredentialsResource, + IWebBotAuthResource, +} from '@stripe/link-sdk'; +import { Cli } from 'incur'; +import type { CliAuthStorage } from '../../auth/storage'; +import { requireAuthGuard } from '../../utils/require-auth'; +import { sanitizeDeep } from '../../utils/sanitize-text'; +import { requestArgs, requestOptions } from './schema'; + +function parseBody(body: string): unknown { + try { + return sanitizeDeep(JSON.parse(body)); + } catch { + return sanitizeDeep(body); + } +} + +export function createRequestCli( + createCredentialsResource: () => ICredentialsResource, + createWebBotAuthResource: () => IWebBotAuthResource, + authStorage?: CliAuthStorage, + envAccessToken?: string, +) { + return Cli.create('request', { + description: + 'Make an HTTPS request that satisfies an AAP identity-claims challenge with selective disclosure and a request-specific Web Bot Auth signature.', + args: requestArgs, + options: requestOptions, + alias: { method: 'X', data: 'd', header: 'H' }, + // Deliberately not 'agent-only': this is a human-facing command, and that policy + // suppresses all output on a TTY unless --format is passed explicitly. + async run(c) { + // Root commands don't take middleware, so guard inline. + requireAuthGuard(c, authStorage, envAccessToken); + + const { url } = c.args; + const { claims, method, data, header, keyFile, keyType } = c.options; + + const { + buildPresentation, + buildRequestHeaders, + claimReferenceKey, + contentDigest, + formatClaimReference, + parseClaimList, + parseClaimsChallenge, + setRequestHeader, + supportsPreProvisionedPresentation, + } = await import('./present'); + + let headers: Record; + try { + headers = buildRequestHeaders(data, header); + } catch (error) { + return c.error({ + code: 'INVALID_INPUT', + message: (error as Error).message, + }); + } + + const httpMethod = ( + method ?? (data !== undefined ? 'POST' : 'GET') + ).toUpperCase(); + let target: URL; + try { + target = new URL(url); + const hostname = target.hostname.replace(/^\[|\]$/g, ''); + const loopback = + hostname === 'localhost' || + hostname === '::1' || + hostname.startsWith('127.'); + if ( + (target.protocol !== 'https:' && + !(target.protocol === 'http:' && loopback)) || + target.username || + target.password || + target.hash + ) { + throw new Error( + 'URL must be HTTPS (HTTP is allowed only for loopback development)', + ); + } + } catch (error) { + return c.error({ + code: 'INVALID_INPUT', + message: + error instanceof TypeError + ? `Invalid URL: ${url}` + : (error as Error).message, + }); + } + const send = (requestHeaders: Record) => + fetch(url, { + method: httpMethod, + body: data, + headers: requestHeaders, + redirect: 'manual', + }); + + // 1. Try without identity — most requests need nothing more. + const probe = await send(headers); + const probeBody = await probe.text(); + let challenge: ReturnType; + try { + challenge = parseClaimsChallenge(probe, probeBody); + } catch (error) { + return c.error({ + code: 'INVALID_CHALLENGE', + message: (error as Error).message, + }); + } + + if (!challenge) { + return { + url, + status: probe.status, + identity_required: false, + response: parseBody(probeBody), + }; + } + if (challenge.aud !== target.origin) { + return c.error({ + code: 'INVALID_CHALLENGE', + message: `Challenge audience ${challenge.aud} does not exactly match ${target.origin}.`, + }); + } + if (!supportsPreProvisionedPresentation(challenge)) { + return c.error({ + code: 'UNSUPPORTED_FORMAT', + message: + 'The verifier does not accept the supported dc+sd-jwt presentation format.', + }); + } + + // 2. The server asked who we are. Disclose what it asked for, unless the + // caller narrowed it with --claims. + const claimOverride = parseClaimList(claims); + if (claimOverride) { + const challenged = new Set( + challenge.claims.map((claim) => claimReferenceKey(claim)), + ); + const extra = claimOverride.filter( + (claim) => !challenged.has(claimReferenceKey(claim)), + ); + if (extra.length > 0) { + return c.error({ + code: 'INVALID_INPUT', + message: `--claims may only narrow the verifier request; not requested: ${extra.map(formatClaimReference).join(', ')}.`, + }); + } + } + const requested = claimOverride ?? challenge.claims; + + const { issueCredential } = await import('../credentials/issue'); + const credential = await issueCredential({ + resource: createCredentialsResource(), + keyFile, + keyType, + }); + let credentialIssuerUrl: URL; + try { + credentialIssuerUrl = new URL(credential.issuer); + if (credentialIssuerUrl.protocol !== 'https:') { + throw new TypeError('Credential issuer must use HTTPS'); + } + } catch { + return c.error({ + code: 'INVALID_CREDENTIAL', + message: `Credential returned an invalid issuer: ${credential.issuer}.`, + }); + } + if (challenge.trusted_issuers) { + const trusted = challenge.trusted_issuers.some((issuer) => { + try { + return new URL(issuer).origin === credentialIssuerUrl.origin; + } catch { + return false; + } + }); + if (!trusted) { + return c.error({ + code: 'UNTRUSTED_ISSUER', + message: `The credential issuer ${credential.issuer} is not trusted by the verifier.`, + }); + } + } + + const presented = buildPresentation({ + credential: credential.credential, + keyFile, + keyType, + aud: challenge.aud, + nonce: challenge.nonce, + disclose: requested, + }); + + if (presented.unavailable.length > 0) { + return c.error({ + code: 'CLAIMS_UNAVAILABLE', + message: `The credential from ${credential.issuer} cannot disclose: ${presented.unavailable.map(formatClaimReference).join(', ')}. It holds: ${Object.keys(credential.claims).join(', ')}.`, + }); + } + + // 3. Bind the presentation and request body into a request-specific Web + // Bot Auth HTTP Message Signature, then retry. + const finalHeaders = { ...headers }; + setRequestHeader( + finalHeaders, + 'Identity-Presentation', + presented.presentation, + ); + if (data !== undefined) { + setRequestHeader(finalHeaders, 'Content-Digest', contentDigest(data)); + } + const webBotAuth = await createWebBotAuthResource().signRequest({ + url, + method: httpMethod, + headers: finalHeaders, + ...(data !== undefined ? { body: data } : {}), + }); + setRequestHeader(finalHeaders, 'Signature', webBotAuth.signature); + setRequestHeader( + finalHeaders, + 'Signature-Input', + webBotAuth.signature_input, + ); + setRequestHeader( + finalHeaders, + 'Signature-Agent', + webBotAuth.signature_agent, + ); + const final = await send(finalHeaders); + const finalBody = await final.text(); + + return { + url, + status: final.status, + identity_required: true, + challenge: { + claims: challenge.claims, + aud: challenge.aud, + ...(challenge.purpose ? { purpose: challenge.purpose } : {}), + ...(challenge.trusted_issuers + ? { trusted_issuers: challenge.trusted_issuers } + : {}), + }, + identity: { + issuer: credential.issuer, + issuer_metadata: new URL( + '/.well-known/aap-issuer', + credentialIssuerUrl, + ).href, + disclosed: presented.disclosed, + withheld: presented.withheld, + credential_expires_at: credential.expires_at, + holder_key: credential.holder_key.path, + }, + response: parseBody(finalBody), + }; + }, + }); +} diff --git a/packages/cli/src/commands/request/present.test.ts b/packages/cli/src/commands/request/present.test.ts new file mode 100644 index 00000000..358f32f7 --- /dev/null +++ b/packages/cli/src/commands/request/present.test.ts @@ -0,0 +1,143 @@ +import { createHash } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { buildPresentation, parseClaimsChallenge } from './present'; + +function encoded(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +function digest(disclosure: string, algorithm = 'sha384'): string { + return createHash(algorithm).update(disclosure).digest('base64url'); +} + +function issuerJwt(payload: Record): string { + return `${encoded({ alg: 'EdDSA' })}.${encoded(payload)}.issuer-signature`; +} + +describe('identity presentation', () => { + const temporaryDirectories: string[] = []; + + afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } + }); + + it('requires the complete AAP challenge envelope', () => { + const response = { + status: 401, + headers: new Headers({ + 'WWW-Authenticate': 'Identity-Presentation', + 'Content-Type': 'application/problem+json; charset=utf-8', + }), + }; + + expect( + parseClaimsChallenge( + response, + JSON.stringify({ + type: 'urn:aap:claims-required', + aud: 'https://merchant.example', + nonce: 'single-use', + claims: ['email', ['address', 'street']], + formats: ['dc+sd-jwt'], + trusted_issuers: ['https://issuer.example'], + purpose: 'Verify\u001b[2J identity', + }), + ), + ).toEqual({ + aud: 'https://merchant.example', + nonce: 'single-use', + claims: ['email', ['address', 'street']], + formats: ['dc+sd-jwt'], + trusted_issuers: ['https://issuer.example'], + purpose: 'Verify identity', + }); + + expect( + parseClaimsChallenge( + { ...response, headers: new Headers() }, + JSON.stringify({ type: 'urn:aap:claims-required' }), + ), + ).toBeNull(); + }); + + it('selects nested claim paths and uses the credential _sd_alg', () => { + const email = encoded(['salt-email', 'email', 'a@example.com']); + const street = encoded(['salt-street', 'street', 'Main Street']); + const address = encoded([ + 'salt-address', + 'address', + { _sd: [digest(street)] }, + ]); + const role0 = encoded(['salt-role-0', { name: 'reader' }]); + const role1 = encoded(['salt-role-1', { name: 'admin' }]); + const roles = encoded([ + 'salt-roles', + 'roles', + [{ '...': digest(role0) }, { '...': digest(role1) }], + ]); + const jwt = issuerJwt({ + _sd_alg: 'sha-384', + _sd: [digest(email), digest(address), digest(roles)], + }); + const credential = [ + jwt, + email, + street, + address, + role0, + role1, + roles, + '', + ].join('~'); + const directory = mkdtempSync(join(tmpdir(), 'link-cli-request-')); + temporaryDirectories.push(directory); + + const result = buildPresentation({ + credential, + keyFile: join(directory, 'holder.jwk'), + keyType: 'ed25519', + aud: 'https://merchant.example', + nonce: 'single-use', + disclose: ['email', ['address', 'street'], ['roles', 1, 'name']], + }); + + expect(result.disclosed).toEqual([ + 'email', + ['address', 'street'], + ['roles', 1, 'name'], + ]); + expect(result.unavailable).toEqual([]); + const parts = result.presentation.split('~'); + expect(parts.slice(1, -1)).toEqual([email, street, address, role1, roles]); + const sdPart = `${parts.slice(0, -1).join('~')}~`; + const kbPayload = JSON.parse( + Buffer.from(parts.at(-1)?.split('.')[1] ?? '', 'base64url').toString( + 'utf8', + ), + ); + expect(kbPayload.sd_hash).toBe( + createHash('sha384').update(sdPart).digest('base64url'), + ); + }); + + it('rejects an unsupported SD-JWT hash algorithm', () => { + const directory = mkdtempSync(join(tmpdir(), 'link-cli-request-')); + temporaryDirectories.push(directory); + + expect(() => + buildPresentation({ + credential: `${issuerJwt({ _sd_alg: 'md5', _sd: [] })}~`, + keyFile: join(directory, 'holder.jwk'), + keyType: 'ed25519', + aud: 'https://merchant.example', + nonce: 'single-use', + disclose: ['email'], + }), + ).toThrow('Unsupported SD-JWT hash algorithm'); + }); +}); diff --git a/packages/cli/src/commands/request/present.ts b/packages/cli/src/commands/request/present.ts new file mode 100644 index 00000000..94e3f1a3 --- /dev/null +++ b/packages/cli/src/commands/request/present.ts @@ -0,0 +1,415 @@ +import { createHash, createSign, sign as signEd25519 } from 'node:crypto'; +import { sanitizeDeep } from '../../utils/sanitize-text'; +import { loadOrCreateHolderKey } from '../credentials/holder-key'; +import type { HolderKeyType } from '../credentials/holder-key'; + +export type ClaimPathComponent = string | number | null; +export type ClaimReference = string | ClaimPathComponent[]; + +/** The claims-required challenge a verifier returns (AAP Phase 5 "Disclosure"). */ +export interface ClaimsChallenge { + aud: string; + nonce: string; + claims: ClaimReference[]; + purpose?: string; + formats: string[]; + trusted_issuers?: string[]; +} + +const CLAIMS_REQUIRED_TYPE = 'urn:aap:claims-required'; +const SUPPORTED_FORMAT = 'dc+sd-jwt'; + +function base64url(input: Buffer | Uint8Array | string): string { + return Buffer.from(input as Buffer).toString('base64url'); +} + +function jsonSegment(value: unknown): string { + return base64url(JSON.stringify(value)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseClaimReference(value: unknown): ClaimReference | null { + if (typeof value === 'string' && value.length > 0) { + return value; + } + if ( + !Array.isArray(value) || + value.length === 0 || + typeof value[0] !== 'string' || + value[0].length === 0 + ) { + return null; + } + for (const component of value) { + if ( + component !== null && + typeof component !== 'string' && + !( + typeof component === 'number' && + Number.isInteger(component) && + component >= 0 + ) + ) { + return null; + } + } + return value as ClaimPathComponent[]; +} + +/** + * Recognizes a claims-required challenge only when all protocol signals agree. + * Unrelated 401 responses pass through; malformed AAP challenges fail closed. + */ +export function parseClaimsChallenge( + response: Pick, + body: string, +): ClaimsChallenge | null { + if (response.status !== 401) { + return null; + } + const authenticate = response.headers.get('www-authenticate') ?? ''; + if (!/(?:^|,)\s*Identity-Presentation(?:\s|,|$)/i.test(authenticate)) { + return null; + } + const contentType = (response.headers.get('content-type') ?? '') + .split(';', 1)[0] + .trim() + .toLowerCase(); + if (contentType !== 'application/problem+json') { + return null; + } + + let parsed: unknown; + try { + parsed = sanitizeDeep(JSON.parse(body)); + } catch { + throw new Error('Invalid claims challenge: body is not JSON'); + } + if (!isRecord(parsed) || parsed.type !== CLAIMS_REQUIRED_TYPE) { + return null; + } + if ( + typeof parsed.aud !== 'string' || + parsed.aud.length === 0 || + typeof parsed.nonce !== 'string' || + parsed.nonce.length === 0 || + !Array.isArray(parsed.claims) || + !Array.isArray(parsed.formats) + ) { + throw new Error('Invalid claims challenge: required fields are missing'); + } + + const claims = parsed.claims.map(parseClaimReference); + if (claims.some((claim) => claim === null)) { + throw new Error('Invalid claims challenge: malformed claim reference'); + } + if (!parsed.formats.every((format) => typeof format === 'string')) { + throw new Error('Invalid claims challenge: malformed formats'); + } + if ( + parsed.trusted_issuers !== undefined && + (!Array.isArray(parsed.trusted_issuers) || + !parsed.trusted_issuers.every( + (issuer) => typeof issuer === 'string' && issuer.length > 0, + )) + ) { + throw new Error('Invalid claims challenge: malformed trusted_issuers'); + } + + return { + aud: parsed.aud, + nonce: parsed.nonce, + claims: claims as ClaimReference[], + formats: parsed.formats as string[], + ...(typeof parsed.purpose === 'string' ? { purpose: parsed.purpose } : {}), + ...(parsed.trusted_issuers !== undefined + ? { trusted_issuers: parsed.trusted_issuers as string[] } + : {}), + }; +} + +export function supportsPreProvisionedPresentation( + challenge: ClaimsChallenge, +): boolean { + return challenge.formats.includes(SUPPORTED_FORMAT); +} + +export interface Presentation { + presentation: string; + disclosed: ClaimReference[]; + withheld: string[]; + /** Claims asked for that the credential cannot selectively disclose. */ + unavailable: ClaimReference[]; +} + +interface DecodedDisclosure { + encoded: string; + digest: string; + name?: string; + value: unknown; +} + +function decodeJsonSegment(segment: string, label: string): unknown { + try { + return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')); + } catch (error) { + throw new Error(`Invalid ${label}`, { cause: error }); + } +} + +function resolveSdHashAlgorithm(payload: Record): { + nodeName: 'sha256' | 'sha384' | 'sha512'; + sdName: 'sha-256' | 'sha-384' | 'sha-512'; +} { + const sdName = payload._sd_alg ?? 'sha-256'; + if (sdName === 'sha-256') { + return { nodeName: 'sha256', sdName }; + } + if (sdName === 'sha-384') { + return { nodeName: 'sha384', sdName }; + } + if (sdName === 'sha-512') { + return { nodeName: 'sha512', sdName }; + } + throw new Error(`Unsupported SD-JWT hash algorithm: ${String(sdName)}`); +} + +function decodeDisclosures( + encodedDisclosures: string[], + hashName: string, +): Map { + const disclosures = new Map(); + for (const encoded of encodedDisclosures) { + const value = decodeJsonSegment(encoded, 'SD-JWT disclosure'); + if ( + !Array.isArray(value) || + (value.length !== 2 && value.length !== 3) || + (value.length === 3 && typeof value[1] !== 'string') + ) { + throw new Error('Invalid SD-JWT disclosure'); + } + const digest = base64url(createHash(hashName).update(encoded).digest()); + disclosures.set(digest, { + encoded, + digest, + ...(value.length === 3 ? { name: value[1] as string } : {}), + value: value.length === 3 ? value[2] : value[1], + }); + } + return disclosures; +} + +function revealArrayElement( + element: unknown, + remainingPath: ClaimPathComponent[], + disclosures: Map, + selected: Set, +): boolean { + if (isRecord(element) && typeof element['...'] === 'string') { + const disclosure = disclosures.get(element['...']); + if (!disclosure || disclosure.name !== undefined) { + return false; + } + selected.add(disclosure.digest); + return revealPath(disclosure.value, remainingPath, disclosures, selected); + } + return revealPath(element, remainingPath, disclosures, selected); +} + +function revealPath( + node: unknown, + path: ClaimPathComponent[], + disclosures: Map, + selected: Set, +): boolean { + if (path.length === 0) { + return true; + } + const [component, ...remaining] = path; + + if (Array.isArray(node)) { + if (component === null) { + let matched = false; + for (const element of node) { + matched = + revealArrayElement(element, remaining, disclosures, selected) || + matched; + } + return matched; + } + if (typeof component !== 'number' || component >= node.length) { + return false; + } + return revealArrayElement( + node[component], + remaining, + disclosures, + selected, + ); + } + + if (!isRecord(node) || typeof component !== 'string') { + return false; + } + if (Object.hasOwn(node, component)) { + return revealPath(node[component], remaining, disclosures, selected); + } + const digests = Array.isArray(node._sd) ? node._sd : []; + for (const digest of digests) { + if (typeof digest !== 'string') { + continue; + } + const disclosure = disclosures.get(digest); + if (disclosure?.name === component) { + selected.add(disclosure.digest); + return revealPath(disclosure.value, remaining, disclosures, selected); + } + } + return false; +} + +function claimPath(reference: ClaimReference): ClaimPathComponent[] { + return typeof reference === 'string' ? [reference] : reference; +} + +export function claimReferenceKey(reference: ClaimReference): string { + return JSON.stringify(claimPath(reference)); +} + +/** + * Builds an SD-JWT-VC presentation with only the disclosures needed to resolve + * the requested claim references, including nested claims path pointers. + */ +export function buildPresentation(options: { + credential: string; + keyFile: string; + keyType: HolderKeyType; + aud: string; + nonce: string; + disclose: ClaimReference[]; +}): Presentation { + const { credential, keyFile, keyType, aud, nonce, disclose } = options; + const [issuerJwt, ...rest] = credential.split('~'); + const jwtParts = issuerJwt.split('.'); + if (jwtParts.length !== 3) { + throw new Error('Invalid SD-JWT issuer credential'); + } + const payload = decodeJsonSegment(jwtParts[1], 'SD-JWT payload'); + if (!isRecord(payload)) { + throw new Error('Invalid SD-JWT payload'); + } + const hashAlgorithm = resolveSdHashAlgorithm(payload); + const available = rest.filter(Boolean); + const disclosures = decodeDisclosures(available, hashAlgorithm.nodeName); + const selected = new Set(); + const disclosed: ClaimReference[] = []; + const unavailable: ClaimReference[] = []; + + for (const reference of disclose) { + const candidate = new Set(selected); + if (revealPath(payload, claimPath(reference), disclosures, candidate)) { + selected.clear(); + for (const digest of candidate) { + selected.add(digest); + } + disclosed.push(reference); + } else { + unavailable.push(reference); + } + } + + const kept = available.filter((encoded) => { + for (const disclosure of disclosures.values()) { + if (disclosure.encoded === encoded) { + return selected.has(disclosure.digest); + } + } + return false; + }); + const withheld = Array.from(disclosures.values()) + .filter((disclosure) => !selected.has(disclosure.digest)) + .map((disclosure) => disclosure.name ?? '[array element]'); + + // Everything up to and including the final `~` is what sd_hash covers. + const sdPart = `${[issuerJwt, ...kept].join('~')}~`; + const holderKey = loadOrCreateHolderKey(keyFile, keyType); + const alg = holderKey.type === 'ed25519' ? 'EdDSA' : 'ES256'; + const header = jsonSegment({ typ: 'kb+jwt', alg }); + const kbPayload = jsonSegment({ + aud, + nonce, + iat: Math.floor(Date.now() / 1000), + sd_hash: base64url( + createHash(hashAlgorithm.nodeName).update(sdPart).digest(), + ), + }); + const signingInput = Buffer.from(`${header}.${kbPayload}`); + const signature = + alg === 'EdDSA' + ? signEd25519(null, signingInput, holderKey.privateKey) + : createSign('sha256') + .update(signingInput) + .sign({ key: holderKey.privateKey, dsaEncoding: 'ieee-p1363' }); + + return { + presentation: `${sdPart}${header}.${kbPayload}.${base64url(signature)}`, + disclosed, + withheld, + unavailable, + }; +} + +export function parseClaimList( + claims: string | undefined, +): ClaimReference[] | null { + if (claims === undefined) { + return null; + } + const parsed = claims + .split(',') + .map((claim) => claim.trim()) + .filter(Boolean); + return parsed.length > 0 ? parsed : null; +} + +export function buildRequestHeaders( + data: string | undefined, + headers: string[], +): Record { + const result: Record = {}; + if (data !== undefined) { + result['Content-Type'] = 'application/json'; + } + for (const header of headers) { + const index = header.indexOf(':'); + if (index === -1) { + throw new Error(`Invalid header "${header}". Use "Name: Value" format.`); + } + result[header.slice(0, index).trim()] = header.slice(index + 1).trim(); + } + return result; +} + +export function setRequestHeader( + headers: Record, + name: string, + value: string, +): void { + for (const existing of Object.keys(headers)) { + if (existing.toLowerCase() === name.toLowerCase()) { + delete headers[existing]; + } + } + headers[name] = value; +} + +export function contentDigest(body: string): string { + return `sha-256=:${createHash('sha256').update(body).digest('base64')}:`; +} + +export function formatClaimReference(reference: ClaimReference): string { + return typeof reference === 'string' ? reference : JSON.stringify(reference); +} diff --git a/packages/cli/src/commands/request/schema.ts b/packages/cli/src/commands/request/schema.ts new file mode 100644 index 00000000..a8703413 --- /dev/null +++ b/packages/cli/src/commands/request/schema.ts @@ -0,0 +1,44 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { z } from 'incur'; + +export const requestArgs = z.object({ + url: z + .string() + .describe( + 'HTTPS URL to request (HTTP is accepted only for loopback development)', + ), +}); + +export const requestOptions = z.object({ + claims: z + .string() + .optional() + .describe( + 'Comma-separated top-level claims to disclose, e.g. "email,given_name,family_name". Only these are sent, even if the credential holds more. Defaults to the server claim references, including nested paths.', + ), + method: z + .string() + .optional() + .describe('HTTP method (default: GET, or POST if --data is provided)'), + data: z + .string() + .optional() + .describe('Request body (implies POST if --method is not set)'), + header: z + .array(z.string()) + .default([]) + .describe('Request header in "Name: Value" format (repeatable)'), + keyFile: z + .string() + .default(join(homedir(), '.link', 'holder-key.jwk')) + .describe( + 'Path to the holder private key (JWK), generated with 0600 permissions if absent. The credential is bound to this key.', + ), + keyType: z + .enum(['ed25519', 'p256']) + .default('ed25519') + .describe( + 'Holder key type to generate when --key-file does not exist yet. Ignored when it does.', + ), +}); diff --git a/packages/sdk/src/resources/__tests__/web-bot-auth.test.ts b/packages/sdk/src/resources/__tests__/web-bot-auth.test.ts index 4a0dd28e..34d13005 100644 --- a/packages/sdk/src/resources/__tests__/web-bot-auth.test.ts +++ b/packages/sdk/src/resources/__tests__/web-bot-auth.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { LinkApiError, LinkSdkError } from '@/errors'; import { WebBotAuthResource } from '@/resources/web-bot-auth'; import type { WebBotAuthBlock } from '@/types/index'; @@ -185,4 +186,83 @@ describe('WebBotAuthResource', () => { expect(err.message).toMatch('Invalid URL'); }); }); + + describe('signRequest', () => { + it('requests and validates a request-specific signature', async () => { + const requestBlock: WebBotAuthBlock = { + ...webBotAuthBlock, + signature_input: + 'agent=("@method" "@authority" "@path" "signature-agent" "identity-presentation" "content-digest");created=1715400100;expires=1715400160;keyid="stub_keyid";tag="web-bot-auth"', + }; + mockFetchResponse(200, { web_bot_auth: requestBlock }); + const requestBody = '{"purchase":true}'; + const requestDigest = `sha-256=:${createHash('sha256') + .update(requestBody) + .digest('base64')}:`; + + const result = await resource.signRequest({ + url: validUrl, + method: 'POST', + headers: { + 'Identity-Presentation': 'issuer-jwt~disclosure~kb-jwt', + 'Content-Digest': requestDigest, + }, + body: requestBody, + }); + + expect(result).toEqual(requestBlock); + const call = mockFetch.mock.calls[0]; + expect(call).toBeDefined(); + if (!call) { + throw new Error('Expected Web Bot Auth request'); + } + const [, opts] = call; + expect(JSON.parse(opts.body)).toEqual({ + url: validUrl, + method: 'POST', + headers: { + 'identity-presentation': 'issuer-jwt~disclosure~kb-jwt', + 'content-digest': requestDigest, + }, + body: requestBody, + }); + }); + + it('fails closed when a required component is unsigned', async () => { + mockFetchResponse(200, credentialsResponse); + + await expect( + resource.signRequest({ + url: validUrl, + method: 'GET', + headers: { + 'Identity-Presentation': 'issuer-jwt~disclosure~kb-jwt', + }, + }), + ).rejects.toThrow( + 'Web Bot Auth signature does not cover required components', + ); + }); + + it('never caches request-specific signatures', async () => { + const requestBlock: WebBotAuthBlock = { + ...webBotAuthBlock, + signature_input: + 'agent=("@method" "@authority" "@path" "signature-agent" "identity-presentation");created=1715400100;expires=1715400160;keyid="stub_keyid";tag="web-bot-auth"', + }; + mockFetchResponse(200, { web_bot_auth: requestBlock }); + const request = { + url: validUrl, + method: 'GET', + headers: { + 'Identity-Presentation': 'issuer-jwt~disclosure~kb-jwt', + }, + }; + + await resource.signRequest(request); + await resource.signRequest(request); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/packages/sdk/src/resources/base.ts b/packages/sdk/src/resources/base.ts index ef24d68b..1658cea5 100644 --- a/packages/sdk/src/resources/base.ts +++ b/packages/sdk/src/resources/base.ts @@ -12,6 +12,7 @@ export interface ApiFetchOptions { headers?: Record; body?: string; signal?: AbortSignal; + redirect?: RequestRedirect; } export interface ApiFetchResult { @@ -82,6 +83,7 @@ export abstract class BaseResource { ...(opts.headers !== undefined && { headers: opts.headers }), ...(opts.body !== undefined && { body: opts.body }), ...(opts.signal !== undefined && { signal: opts.signal }), + ...(opts.redirect !== undefined && { redirect: opts.redirect }), }; response = await this.fetchImpl(opts.url, init); } catch (error) { diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index cd16f9b9..31175c8a 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -111,8 +111,16 @@ export interface IUserInfoResource { retrieve(): Promise; } +export interface WebBotAuthRequest { + url: string; + method: string; + headers: Record; + body?: string; +} + export interface IWebBotAuthResource { signUrl(url: string): Promise; + signRequest(request: WebBotAuthRequest): Promise; } export interface ListTransactionsParams { diff --git a/packages/sdk/src/resources/web-bot-auth.ts b/packages/sdk/src/resources/web-bot-auth.ts index dc9e044b..7c3f9efd 100644 --- a/packages/sdk/src/resources/web-bot-auth.ts +++ b/packages/sdk/src/resources/web-bot-auth.ts @@ -1,7 +1,11 @@ +import { createHash } from 'node:crypto'; import type { LinkOptions } from '@/config'; import { LinkSdkError } from '@/errors'; import { BaseResource } from '@/resources/base'; -import type { IWebBotAuthResource } from '@/resources/interfaces'; +import type { + IWebBotAuthResource, + WebBotAuthRequest, +} from '@/resources/interfaces'; import type { WebBotAuthBlock } from '@/types/index'; import { z } from 'zod'; @@ -13,11 +17,11 @@ interface CacheEntry { const EXPIRY_BUFFER_MS = 30_000; const webBotAuthBlockSchema = z.looseObject({ - signature: z.string(), - signature_input: z.string(), - signature_agent: z.string(), - authority: z.string(), - expires_at: z.string(), + signature: z.string().min(1), + signature_input: z.string().min(1), + signature_agent: z.string().min(1), + authority: z.string().min(1), + expires_at: z.string().min(1), }); const webBotAuthResponseSchema = z.looseObject({ web_bot_auth: webBotAuthBlockSchema, @@ -33,6 +37,52 @@ export class WebBotAuthResource super(options, '/web_bot_auth/sign'); } + private parseBlock( + operation: string, + status: number, + data: unknown, + rawBody: string, + requiredComponents: string[] = [], + ): WebBotAuthBlock { + if (status < 200 || status >= 300) { + this.throwApiError(operation, status, data, rawBody); + } + + return this.parseResponse(operation, status, () => { + const webBotAuth = webBotAuthResponseSchema.parse(data).web_bot_auth; + if (Number.isNaN(Date.parse(webBotAuth.expires_at))) { + throw new LinkSdkError( + `Credentials response has invalid expires_at: ${webBotAuth.expires_at}`, + ); + } + const signatureInput = webBotAuth.signature_input.toLowerCase(); + const missing = requiredComponents.filter( + (component) => !signatureInput.includes(`"${component.toLowerCase()}"`), + ); + if (missing.length > 0) { + throw new LinkSdkError( + `Web Bot Auth signature does not cover required components: ${missing.join(', ')}`, + ); + } + if (requiredComponents.length > 0) { + const created = signatureInput.match(/;created=(\d+)/)?.[1]; + const expires = signatureInput.match(/;expires=(\d+)/)?.[1]; + if ( + !created || + !expires || + Number(expires) <= Number(created) || + !/;keyid="[^"]+"/.test(signatureInput) || + !signatureInput.includes(';tag="web-bot-auth"') + ) { + throw new LinkSdkError( + 'Web Bot Auth signature is missing required freshness, keyid, or tag parameters', + ); + } + } + return webBotAuth; + }); + } + async signUrl(url: string): Promise { let authority: string; try { @@ -51,25 +101,88 @@ export class WebBotAuthResource url: this.endpoint, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), + redirect: 'manual', }); - if (status < 200 || status >= 300) { - this.throwApiError('get web bot auth headers', status, data, rawBody); - } - - const webBotAuth = this.parseResponse( + const webBotAuth = this.parseBlock( 'get web bot auth headers', status, - () => webBotAuthResponseSchema.parse(data).web_bot_auth, + data, + rawBody, ); const expiresAt = Date.parse(webBotAuth.expires_at); - if (Number.isNaN(expiresAt)) { - throw new LinkSdkError( - `Credentials response has invalid expires_at: ${webBotAuth.expires_at}`, - ); - } this.cache.set(authority, { block: webBotAuth, expiresAt }); return webBotAuth; } + + /** + * Returns a request-specific Web Bot Auth signature. Unlike `signUrl`, this + * method is never cached because the signature binds the method, path, + * identity presentation, and body digest of one outbound request. + */ + async signRequest(request: WebBotAuthRequest): Promise { + let parsedUrl: URL; + try { + parsedUrl = new URL(request.url); + } catch { + throw new LinkSdkError(`Invalid URL: ${request.url}`); + } + + const normalizedHeaders = Object.fromEntries( + Object.entries(request.headers).map(([name, value]) => [ + name.toLowerCase(), + value, + ]), + ); + const requiredComponents = [ + '@method', + '@authority', + '@path', + 'signature-agent', + ]; + if (normalizedHeaders.authorization !== undefined) { + requiredComponents.push('authorization'); + } + if (normalizedHeaders['identity-presentation'] !== undefined) { + requiredComponents.push('identity-presentation'); + } + if (request.body !== undefined) { + const expectedDigest = `sha-256=:${createHash('sha256') + .update(request.body) + .digest('base64')}:`; + if (normalizedHeaders['content-digest'] !== expectedDigest) { + throw new LinkSdkError( + 'Content-Digest must be SHA-256 of the exact request body', + ); + } + requiredComponents.push('content-digest'); + } + + const { status, data, rawBody } = await this.apiFetch({ + method: 'POST', + url: this.endpoint, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: request.url, + method: request.method.toUpperCase(), + headers: normalizedHeaders, + ...(request.body !== undefined ? { body: request.body } : {}), + }), + redirect: 'manual', + }); + const block = this.parseBlock( + 'get request-specific web bot auth headers', + status, + data, + rawBody, + requiredComponents, + ); + if (block.authority !== parsedUrl.host) { + throw new LinkSdkError( + `Web Bot Auth signature authority ${block.authority} does not match ${parsedUrl.host}`, + ); + } + return block; + } } From 423436a28598ccb684396b043f073c3ca89b1052 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Thu, 27 Aug 2026 12:02:10 -0400 Subject: [PATCH 17/26] chore: drop the AAP acronym from request docs and comments Describe identity-claims challenges in plain language. Keep claims-required URNs as wire identifiers. Co-authored-by: Cursor Committed-By-Agent: cursor --- CLAUDE.md | 2 +- README.md | 2 +- packages/cli/src/commands/request/index.tsx | 2 +- packages/cli/src/commands/request/present.test.ts | 2 +- packages/cli/src/commands/request/present.ts | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a4921915..bc12d622 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,7 +133,7 @@ Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENT - The private key is persisted at `--key-file` (default `~/.link/holder-key.jwk`, mode 0600) and reused across runs. - Requires `userinfo:read` and `payment_methods.agentic`; no additional OAuth scope is required. -### request command (AAP) +### request command `request [--claims "a,b,c"] [-X ] [-d ] [-H
]... [--key-file ] [--key-type ed25519|p256]` — satisfies a pre-provisioned identity-claims challenge and retries with a holder-bound presentation. HTTPS is required except for loopback development. diff --git a/README.md b/README.md index 51710f6a..8dfffa69 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,7 @@ link-cli request https://merchant.example/checkout link-cli request https://merchant.example/checkout --claims email,given_name ``` -When the HTTPS origin returns an AAP `Identity-Presentation` challenge, `request` provisions a holder-bound credential, selects only the requested claims, validates the audience/format/trusted issuer, and retries with both the presentation and a request-specific Web Bot Auth signature. Redirects are not followed. +When the HTTPS origin returns an `Identity-Presentation` challenge, `request` provisions a holder-bound credential, selects only the requested claims, validates the audience/format/trusted issuer, and retries with both the presentation and a request-specific Web Bot Auth signature. Redirects are not followed. ### Spend request lifecycle diff --git a/packages/cli/src/commands/request/index.tsx b/packages/cli/src/commands/request/index.tsx index 534a66c1..c44155b8 100644 --- a/packages/cli/src/commands/request/index.tsx +++ b/packages/cli/src/commands/request/index.tsx @@ -24,7 +24,7 @@ export function createRequestCli( ) { return Cli.create('request', { description: - 'Make an HTTPS request that satisfies an AAP identity-claims challenge with selective disclosure and a request-specific Web Bot Auth signature.', + 'Make an HTTPS request that satisfies an identity-claims challenge with selective disclosure and a request-specific Web Bot Auth signature.', args: requestArgs, options: requestOptions, alias: { method: 'X', data: 'd', header: 'H' }, diff --git a/packages/cli/src/commands/request/present.test.ts b/packages/cli/src/commands/request/present.test.ts index 358f32f7..543ff829 100644 --- a/packages/cli/src/commands/request/present.test.ts +++ b/packages/cli/src/commands/request/present.test.ts @@ -26,7 +26,7 @@ describe('identity presentation', () => { } }); - it('requires the complete AAP challenge envelope', () => { + it('requires the complete claims-required challenge envelope', () => { const response = { status: 401, headers: new Headers({ diff --git a/packages/cli/src/commands/request/present.ts b/packages/cli/src/commands/request/present.ts index 94e3f1a3..36b2d238 100644 --- a/packages/cli/src/commands/request/present.ts +++ b/packages/cli/src/commands/request/present.ts @@ -6,7 +6,7 @@ import type { HolderKeyType } from '../credentials/holder-key'; export type ClaimPathComponent = string | number | null; export type ClaimReference = string | ClaimPathComponent[]; -/** The claims-required challenge a verifier returns (AAP Phase 5 "Disclosure"). */ +/** The claims-required challenge a verifier returns for identity disclosure. */ export interface ClaimsChallenge { aud: string; nonce: string; @@ -61,7 +61,7 @@ function parseClaimReference(value: unknown): ClaimReference | null { /** * Recognizes a claims-required challenge only when all protocol signals agree. - * Unrelated 401 responses pass through; malformed AAP challenges fail closed. + * Unrelated 401 responses pass through; malformed identity challenges fail closed. */ export function parseClaimsChallenge( response: Pick, From 3ab5adc1381965577ae8e19f4de48a6a3076c4f1 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Thu, 27 Aug 2026 16:39:11 -0400 Subject: [PATCH 18/26] chore: keep request unlisted unless LINK_IDENTITY_COMMANDS is set Same discovery gate as attestations and credentials so identity-aware HTTP requests stay off --help, --llms, and MCP by default. Co-authored-by: Cursor Committed-By-Agent: cursor --- CLAUDE.md | 2 ++ README.md | 6 ++++-- packages/cli/src/cli.tsx | 16 ++++++++-------- packages/cli/src/commands/request/index.tsx | 1 + 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bc12d622..63d5b8f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,6 +135,8 @@ Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENT ### request command +Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENTITY_COMMANDS=1` (or `true`). Even when enabled, the command sets `mcp: false` so MCP clients do not see it. + `request [--claims "a,b,c"] [-X ] [-d ] [-H
]... [--key-file ] [--key-type ed25519|p256]` — satisfies a pre-provisioned identity-claims challenge and retries with a holder-bound presentation. HTTPS is required except for loopback development. - Recognizes a challenge only when status is 401, `WWW-Authenticate` includes `Identity-Presentation`, content type is `application/problem+json`, and the body type is `urn:aap:claims-required`. diff --git a/README.md b/README.md index 8dfffa69..4d1a0dda 100644 --- a/README.md +++ b/README.md @@ -299,9 +299,11 @@ LINK_IDENTITY_COMMANDS=1 link-cli identity credentials get --key-file ~/.link/ho ### Identity-aware HTTP requests +Unlisted command: set `LINK_IDENTITY_COMMANDS=1` to enable it. It is omitted from `--help`, `--llms`, and MCP tool lists otherwise. + ```bash -link-cli request https://merchant.example/checkout -link-cli request https://merchant.example/checkout --claims email,given_name +LINK_IDENTITY_COMMANDS=1 link-cli request https://merchant.example/checkout +LINK_IDENTITY_COMMANDS=1 link-cli request https://merchant.example/checkout --claims email,given_name ``` When the HTTPS origin returns an `Identity-Presentation` challenge, `request` provisions a holder-bound credential, selects only the requested claims, validates the audience/format/trusted issuer, and retries with both the presentation and a request-specific Web Bot Auth signature. Redirects are not followed. diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index bc31ee0a..44e61080 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -103,15 +103,15 @@ if (identityCommandsEnabled) { factory.createCredentialsResource(accessToken), }), ); + cli.command( + createRequestCli( + () => factory.createCredentialsResource(), + () => factory.createWebBotAuthResource(), + authStorage, + envAccessToken, + ), + ); } -cli.command( - createRequestCli( - () => factory.createCredentialsResource(), - () => factory.createWebBotAuthResource(), - authStorage, - envAccessToken, - ), -); cli.command( createAuthCli(authRepo, getUpdateInfo, authStorage, envAccessToken), ); diff --git a/packages/cli/src/commands/request/index.tsx b/packages/cli/src/commands/request/index.tsx index c44155b8..15a28bb1 100644 --- a/packages/cli/src/commands/request/index.tsx +++ b/packages/cli/src/commands/request/index.tsx @@ -28,6 +28,7 @@ export function createRequestCli( args: requestArgs, options: requestOptions, alias: { method: 'X', data: 'd', header: 'H' }, + mcp: false, // Deliberately not 'agent-only': this is a human-facing command, and that policy // suppresses all output on a TTY unless --format is passed explicitly. async run(c) { From 6e6f488780f365ef1f4b0b9d5b0900595e6d2b98 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Fri, 28 Aug 2026 10:02:40 -0400 Subject: [PATCH 19/26] feat: nest identity request under the identity command group Expose it as `identity request` and describe presenting signed Link user info instead of protocol jargon. Co-authored-by: Cursor Committed-By-Agent: cursor --- CLAUDE.md | 6 +++--- README.md | 10 ++++------ packages/cli/src/cli.tsx | 10 ++-------- packages/cli/src/commands/identity/index.tsx | 14 ++++++++++++++ packages/cli/src/commands/request/index.tsx | 2 +- packages/cli/src/commands/request/schema.ts | 6 +++--- 6 files changed, 27 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 63d5b8f1..b056d325 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,16 +133,16 @@ Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENT - The private key is persisted at `--key-file` (default `~/.link/holder-key.jwk`, mode 0600) and reused across runs. - Requires `userinfo:read` and `payment_methods.agentic`; no additional OAuth scope is required. -### request command +### identity request command Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENTITY_COMMANDS=1` (or `true`). Even when enabled, the command sets `mcp: false` so MCP clients do not see it. -`request [--claims "a,b,c"] [-X ] [-d ] [-H
]... [--key-file ] [--key-type ed25519|p256]` — satisfies a pre-provisioned identity-claims challenge and retries with a holder-bound presentation. HTTPS is required except for loopback development. +`identity request [--claims "a,b,c"] [-X ] [-d ] [-H
]... [--key-file ] [--key-type ed25519|p256]` — makes an HTTPS request and, if the site asks who you are, presents signed user info from Link. HTTPS is required except for loopback development. - Recognizes a challenge only when status is 401, `WWW-Authenticate` includes `Identity-Presentation`, content type is `application/problem+json`, and the body type is `urn:aap:claims-required`. - Requires the challenge `aud` to exactly equal the request origin, supports `dc+sd-jwt`, and honors `trusted_issuers`. - Supports string and nested claims path pointers. `sd_hash` uses the credential's `_sd_alg` (default `sha-256`). -- The retry uses a request-specific Web Bot Auth HTTP Message Signature covering method, authority, path, `Signature-Agent`, `Identity-Presentation`, any `Authorization`, and `Content-Digest` when a body is present. +- The retry uses a request-specific HTTP Message Signature covering method, authority, path, `Signature-Agent`, `Identity-Presentation`, any `Authorization`, and `Content-Digest` when a body is present. - Redirects are not followed, preventing identity presentations or authorization credentials from crossing origins. ### serve command diff --git a/README.md b/README.md index 4d1a0dda..d7ced8d8 100644 --- a/README.md +++ b/README.md @@ -297,16 +297,14 @@ LINK_IDENTITY_COMMANDS=1 link-cli identity credentials get --key-file ~/.link/ho `identity credentials get` fetches that signed user info and keeps a local key so you can present the same wallet of claims later. Link tells the CLI where to request it; there is no fixed path to hard-code. -### Identity-aware HTTP requests - -Unlisted command: set `LINK_IDENTITY_COMMANDS=1` to enable it. It is omitted from `--help`, `--llms`, and MCP tool lists otherwise. +If a site asks who you are, present that signed user info on an HTTPS request: ```bash -LINK_IDENTITY_COMMANDS=1 link-cli request https://merchant.example/checkout -LINK_IDENTITY_COMMANDS=1 link-cli request https://merchant.example/checkout --claims email,given_name +LINK_IDENTITY_COMMANDS=1 link-cli identity request https://merchant.example/checkout +LINK_IDENTITY_COMMANDS=1 link-cli identity request https://merchant.example/checkout --claims email,given_name ``` -When the HTTPS origin returns an `Identity-Presentation` challenge, `request` provisions a holder-bound credential, selects only the requested claims, validates the audience/format/trusted issuer, and retries with both the presentation and a request-specific Web Bot Auth signature. Redirects are not followed. +`identity request` sends the HTTP request. When the site asks for signed user info, it gets that from Link, shares only the requested fields, and retries. Redirects are not followed. ### Spend request lifecycle diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 44e61080..f6e461d5 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -8,7 +8,6 @@ import { createMppCli } from './commands/mpp'; import { createOnboardCli } from './commands/onboard'; import { createPaymentMethodsCli } from './commands/payment-methods'; import { createReportCli } from './commands/report'; -import { createRequestCli } from './commands/request'; import { createServeCli } from './commands/serve'; import { createShippingAddressCli } from './commands/shipping-address'; import { createSourcesCli } from './commands/sources'; @@ -101,15 +100,10 @@ if (identityCommandsEnabled) { factory.createAttestationsResource(accessToken), createCredentialsResource: (accessToken) => factory.createCredentialsResource(accessToken), - }), - ); - cli.command( - createRequestCli( - () => factory.createCredentialsResource(), - () => factory.createWebBotAuthResource(), + createWebBotAuthResource: () => factory.createWebBotAuthResource(), authStorage, envAccessToken, - ), + }), ); } cli.command( diff --git a/packages/cli/src/commands/identity/index.tsx b/packages/cli/src/commands/identity/index.tsx index 409e4316..05dca4c5 100644 --- a/packages/cli/src/commands/identity/index.tsx +++ b/packages/cli/src/commands/identity/index.tsx @@ -1,10 +1,13 @@ import type { IAttestationsResource, ICredentialsResource, + IWebBotAuthResource, } from '@stripe/link-sdk'; import { Cli } from 'incur'; +import type { CliAuthStorage } from '../../auth/storage'; import { createAttestationsCli } from '../attestations'; import { createCredentialsCli } from '../credentials'; +import { createRequestCli } from '../request'; export function createIdentityCli(options: { createAttestationsResource: ( @@ -13,6 +16,9 @@ export function createIdentityCli(options: { createCredentialsResource: ( accessToken?: string, ) => ICredentialsResource; + createWebBotAuthResource: () => IWebBotAuthResource; + authStorage?: CliAuthStorage; + envAccessToken?: string; }) { const cli = Cli.create('identity', { description: 'Prove your agent and user identity with Link.', @@ -20,5 +26,13 @@ export function createIdentityCli(options: { cli.command(createAttestationsCli(options.createAttestationsResource)); cli.command(createCredentialsCli(options.createCredentialsResource)); + cli.command( + createRequestCli( + () => options.createCredentialsResource(), + options.createWebBotAuthResource, + options.authStorage, + options.envAccessToken, + ), + ); return cli; } diff --git a/packages/cli/src/commands/request/index.tsx b/packages/cli/src/commands/request/index.tsx index 15a28bb1..591afe2e 100644 --- a/packages/cli/src/commands/request/index.tsx +++ b/packages/cli/src/commands/request/index.tsx @@ -24,7 +24,7 @@ export function createRequestCli( ) { return Cli.create('request', { description: - 'Make an HTTPS request that satisfies an identity-claims challenge with selective disclosure and a request-specific Web Bot Auth signature.', + 'Make an HTTPS request. If the site asks who you are, present signed user info from Link.', args: requestArgs, options: requestOptions, alias: { method: 'X', data: 'd', header: 'H' }, diff --git a/packages/cli/src/commands/request/schema.ts b/packages/cli/src/commands/request/schema.ts index a8703413..8184d094 100644 --- a/packages/cli/src/commands/request/schema.ts +++ b/packages/cli/src/commands/request/schema.ts @@ -15,7 +15,7 @@ export const requestOptions = z.object({ .string() .optional() .describe( - 'Comma-separated top-level claims to disclose, e.g. "email,given_name,family_name". Only these are sent, even if the credential holds more. Defaults to the server claim references, including nested paths.', + 'Comma-separated user-info fields to share, e.g. "email,given_name,family_name". Only these are sent. Defaults to what the site asked for.', ), method: z .string() @@ -33,12 +33,12 @@ export const requestOptions = z.object({ .string() .default(join(homedir(), '.link', 'holder-key.jwk')) .describe( - 'Path to the holder private key (JWK), generated with 0600 permissions if absent. The credential is bound to this key.', + 'Path to a local key file. Created if missing. Reuse the same file used by identity credentials get.', ), keyType: z .enum(['ed25519', 'p256']) .default('ed25519') .describe( - 'Holder key type to generate when --key-file does not exist yet. Ignored when it does.', + 'Key type to generate when --key-file does not exist yet. Ignored when it does.', ), }); From 0171396e4e1bd27ac79ece4b5ea0319e6472bae6 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Fri, 28 Aug 2026 11:08:38 -0400 Subject: [PATCH 20/26] feat: present pooled attestation tokens on identity request Spend a local AAT on PrivateToken challenges, sign the retry with Web Bot Auth, and answer combined identity-presentation challenges in the same round. Co-authored-by: Cursor Committed-By-Agent: cursor --- CLAUDE.md | 8 +- README.md | 6 +- .../cli/src/commands/attestations/index.tsx | 13 +- .../src/commands/attestations/pool.test.ts | 74 ++++ .../cli/src/commands/attestations/pool.ts | 155 +++++++ .../cli/src/commands/attestations/schema.ts | 7 + packages/cli/src/commands/identity/index.tsx | 8 +- packages/cli/src/commands/request/index.tsx | 244 +---------- .../cli/src/commands/request/present.test.ts | 25 ++ .../commands/request/private-token.test.ts | 69 +++ .../cli/src/commands/request/private-token.ts | 184 ++++++++ packages/cli/src/commands/request/run.test.ts | 118 +++++ packages/cli/src/commands/request/run.ts | 404 ++++++++++++++++++ packages/cli/src/commands/request/schema.ts | 7 + packages/sdk/src/index.ts | 5 + .../__tests__/attestations-crypto.test.ts | 35 +- .../sdk/src/resources/attestations-crypto.ts | 65 ++- 17 files changed, 1173 insertions(+), 254 deletions(-) create mode 100644 packages/cli/src/commands/attestations/pool.test.ts create mode 100644 packages/cli/src/commands/attestations/pool.ts create mode 100644 packages/cli/src/commands/request/private-token.test.ts create mode 100644 packages/cli/src/commands/request/private-token.ts create mode 100644 packages/cli/src/commands/request/run.test.ts create mode 100644 packages/cli/src/commands/request/run.ts diff --git a/CLAUDE.md b/CLAUDE.md index b056d325..af2ef80c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,11 +114,12 @@ Key input field notes: Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENTITY_COMMANDS=1` (or `true`). Even when enabled, the command sets `mcp: false` so MCP clients do not see it. -`identity attestations request --count [--issuer ] [--access-token ]` — gets privacy-preserving tokens that show Link attests to your agent. Agent-only output. The SDK owns issuance in `packages/sdk/src/resources/attestations.ts` and `attestations-crypto.ts`; CLI schema and registration remain in `packages/cli/src/commands/attestations/`, mounted under `packages/cli/src/commands/identity/`. +`identity attestations request --count [--issuer ] [--access-token ] [--pool-file ]` — gets privacy-preserving tokens that show Link attests to your agent. Agent-only output. The SDK owns issuance in `packages/sdk/src/resources/attestations.ts` and `attestations-crypto.ts`; CLI schema and registration remain in `packages/cli/src/commands/attestations/`, mounted under `packages/cli/src/commands/identity/`. - Discovery: `GET /.well-known/aap-issuer` → metadata, then `GET` its `token_keys` URL. The issuer and every discovered endpoint must use HTTPS on the same DNS origin; redirects and IP-literal hosts are rejected before credentials are sent. - Tokens use a stable challenge: fixed `issuer_name`, empty `redemption_context`, and empty `origin_info`. - Blind signatures are verified after unblinding before final tokens are returned. +- Unused tokens are persisted to `--pool-file` (default `~/.link/aat-pool.json`, mode 0600) so `identity request` can answer `PrivateToken` challenges without a synchronous issuance round-trip. - Server-side max batch is 100. Issuance does not require an additional OAuth scope. - Auth: `--access-token`, else stored CLI credentials. @@ -137,9 +138,10 @@ Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENT Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENTITY_COMMANDS=1` (or `true`). Even when enabled, the command sets `mcp: false` so MCP clients do not see it. -`identity request [--claims "a,b,c"] [-X ] [-d ] [-H
]... [--key-file ] [--key-type ed25519|p256]` — makes an HTTPS request and, if the site asks who you are, presents signed user info from Link. HTTPS is required except for loopback development. +`identity request [--claims "a,b,c"] [-X ] [-d ] [-H
]... [--key-file ] [--key-type ed25519|p256] [--pool-file ]` — makes an HTTPS request and, if the site asks for attestation or who you are, presents a pooled Link attestation token and signed user info. HTTPS is required except for loopback development. -- Recognizes a challenge only when status is 401, `WWW-Authenticate` includes `Identity-Presentation`, content type is `application/problem+json`, and the body type is `urn:aap:claims-required`. +- Recognizes a `PrivateToken` challenge (`WWW-Authenticate: PrivateToken challenge=..., token-key=...`) and answers from `--pool-file` with `Authorization: PrivateToken token=...`. Tokens are single-use; an empty or non-matching pool fails closed (`AAT_POOL_EMPTY` / `AAT_NO_MATCH`). +- Recognizes a claims challenge only when status is 401, `WWW-Authenticate` includes `Identity-Presentation`, content type is `application/problem+json`, and the body type is `urn:aap:claims-required`. Combined `401`s (PrivateToken + Identity-Presentation) are answered in one retry. - Requires the challenge `aud` to exactly equal the request origin, supports `dc+sd-jwt`, and honors `trusted_issuers`. - Supports string and nested claims path pointers. `sd_hash` uses the credential's `_sd_alg` (default `sha-256`). - The retry uses a request-specific HTTP Message Signature covering method, authority, path, `Signature-Agent`, `Identity-Presentation`, any `Authorization`, and `Content-Digest` when a body is present. diff --git a/README.md b/README.md index d7ced8d8..71d1a617 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,7 @@ LINK_IDENTITY_COMMANDS=1 link-cli identity attestations request --count 10 LINK_IDENTITY_COMMANDS=1 link-cli identity attestations request --count 10 --issuer https://api.link.com ``` -`identity attestations request` asks Link for a pool of those tokens (`--count` 1–100). You can pass an HTTPS `--issuer` and an `--access-token`; otherwise stored login credentials are used. Issuer discovery and issuance stay on the issuer's HTTPS DNS origin; redirects and IP-literal hosts are rejected. +`identity attestations request` asks Link for a pool of those tokens (`--count` 1–100) and stores unused tokens in `~/.link/aat-pool.json` for `identity request`. You can pass an HTTPS `--issuer` and an `--access-token`; otherwise stored login credentials are used. Issuer discovery and issuance stay on the issuer's HTTPS DNS origin; redirects and IP-literal hosts are rejected. User info that has been signed, proving it comes from Link: @@ -297,14 +297,14 @@ LINK_IDENTITY_COMMANDS=1 link-cli identity credentials get --key-file ~/.link/ho `identity credentials get` fetches that signed user info and keeps a local key so you can present the same wallet of claims later. Link tells the CLI where to request it; there is no fixed path to hard-code. -If a site asks who you are, present that signed user info on an HTTPS request: +If a site asks for attestation or who you are, present a pooled attestation token and signed user info on an HTTPS request: ```bash LINK_IDENTITY_COMMANDS=1 link-cli identity request https://merchant.example/checkout LINK_IDENTITY_COMMANDS=1 link-cli identity request https://merchant.example/checkout --claims email,given_name ``` -`identity request` sends the HTTP request. When the site asks for signed user info, it gets that from Link, shares only the requested fields, and retries. Redirects are not followed. +`identity request` sends the HTTP request. When the site challenges with `PrivateToken`, it spends one token from the local pool. When the site asks for signed user info, it gets that from Link, shares only the requested fields, and retries. Both challenges can appear on the same `401`. The retry is signed with a request-specific Web Bot Auth HTTP Message Signature covering the presented token and user info. Redirects are not followed. ### Spend request lifecycle diff --git a/packages/cli/src/commands/attestations/index.tsx b/packages/cli/src/commands/attestations/index.tsx index 27c85509..2cff18f4 100644 --- a/packages/cli/src/commands/attestations/index.tsx +++ b/packages/cli/src/commands/attestations/index.tsx @@ -17,12 +17,21 @@ export function createAttestationsCli( mcp: false, outputPolicy: 'agent-only' as const, async run(c) { - const { count, issuer, accessToken } = c.options; + const { count, issuer, accessToken, poolFile } = c.options; + const { remainingCount, saveIssuedTokens } = await import('./pool'); - return createResource(accessToken).request({ + const result = await createResource(accessToken).request({ issuer, count, }); + saveIssuedTokens(poolFile, result); + return { + ...result, + pool: { + path: poolFile, + remaining: remainingCount(poolFile), + }, + }; }, }); diff --git a/packages/cli/src/commands/attestations/pool.test.ts b/packages/cli/src/commands/attestations/pool.test.ts new file mode 100644 index 00000000..d070bf18 --- /dev/null +++ b/packages/cli/src/commands/attestations/pool.test.ts @@ -0,0 +1,74 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { remainingCount, saveIssuedTokens, takeMatchingToken } from './pool'; + +function fakeToken(challengeDigest: Buffer, tokenKeyId: Buffer): string { + const raw = Buffer.alloc(2 + 32 + 32 + 32 + 8); + raw.writeUInt16BE(0x0002, 0); + challengeDigest.copy(raw, 34); + tokenKeyId.copy(raw, 66); + return raw.toString('base64url'); +} + +describe('attestation token pool', () => { + const directories: string[] = []; + + afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } + }); + + function poolPath(): string { + const directory = mkdtempSync(join(tmpdir(), 'link-cli-aat-')); + directories.push(directory); + return join(directory, 'aat-pool.json'); + } + + it('appends issued tokens and pops a matching one', () => { + const path = poolPath(); + const digest = Buffer.alloc(32, 7); + const keyId = Buffer.alloc(32, 9); + const first = fakeToken(digest, keyId); + const second = fakeToken(digest, keyId); + + saveIssuedTokens(path, { + issuer: 'https://api.link.com', + token_key_id: keyId.toString('base64url'), + tokens: [first, second], + }); + expect(remainingCount(path)).toBe(2); + + const spent = takeMatchingToken(path, { + challengeDigest: new Uint8Array(digest), + tokenKeyId: new Uint8Array(keyId), + }); + expect(spent).toEqual({ + token: first, + issuer: 'https://api.link.com', + remaining: 1, + }); + expect(remainingCount(path)).toBe(1); + }); + + it('returns null when the challenge digest does not match', () => { + const path = poolPath(); + const digest = Buffer.alloc(32, 1); + const keyId = Buffer.alloc(32, 2); + saveIssuedTokens(path, { + issuer: 'https://api.link.com', + token_key_id: keyId.toString('base64url'), + tokens: [fakeToken(digest, keyId)], + }); + + expect( + takeMatchingToken(path, { + challengeDigest: new Uint8Array(Buffer.alloc(32, 3)), + tokenKeyId: new Uint8Array(keyId), + }), + ).toBeNull(); + expect(remainingCount(path)).toBe(1); + }); +}); diff --git a/packages/cli/src/commands/attestations/pool.ts b/packages/cli/src/commands/attestations/pool.ts new file mode 100644 index 00000000..0f250cd1 --- /dev/null +++ b/packages/cli/src/commands/attestations/pool.ts @@ -0,0 +1,155 @@ +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +export const DEFAULT_AAT_POOL_PATH = join(homedir(), '.link', 'aat-pool.json'); + +const POOL_FILE_MODE = 0o600; + +interface PoolBatch { + issuer: string; + token_key_id: string; + tokens: string[]; +} + +interface PoolFile { + version: 1; + batches: PoolBatch[]; +} + +function emptyPool(): PoolFile { + return { version: 1, batches: [] }; +} + +function isPoolFile(value: unknown): value is PoolFile { + if (typeof value !== 'object' || value === null) { + return false; + } + const record = value as Record; + if (record.version !== 1 || !Array.isArray(record.batches)) { + return false; + } + return record.batches.every((batch) => { + if (typeof batch !== 'object' || batch === null) { + return false; + } + const entry = batch as Record; + return ( + typeof entry.issuer === 'string' && + typeof entry.token_key_id === 'string' && + Array.isArray(entry.tokens) && + entry.tokens.every((token) => typeof token === 'string') + ); + }); +} + +export function loadPool(path: string): PoolFile { + try { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')); + if (!isPoolFile(parsed)) { + throw new Error(`Attestation pool at ${path} is malformed`); + } + return parsed; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return emptyPool(); + } + throw error; + } +} + +function parsePooledToken( + token: string, +): { challengeDigest: Buffer; tokenKeyId: Buffer } | null { + const raw = Buffer.from(token, 'base64url'); + const prefix = 2 + 32 + 32 + 32; + if (raw.length <= prefix || raw.readUInt16BE(0) !== 0x0002) { + return null; + } + return { + challengeDigest: raw.subarray(34, 66), + tokenKeyId: raw.subarray(66, 98), + }; +} + +function savePool(path: string, pool: PoolFile): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(pool, null, 2)}\n`, { + mode: POOL_FILE_MODE, + }); + chmodSync(path, POOL_FILE_MODE); +} + +export function remainingCount(path: string): number { + return loadPool(path).batches.reduce( + (total, batch) => total + batch.tokens.length, + 0, + ); +} + +export function saveIssuedTokens( + path: string, + issuance: { issuer: string; token_key_id: string; tokens: string[] }, +): void { + const pool = loadPool(path); + const existing = pool.batches.find( + (batch) => + batch.issuer === issuance.issuer && + batch.token_key_id === issuance.token_key_id, + ); + if (existing) { + existing.tokens.push(...issuance.tokens); + } else { + pool.batches.push({ + issuer: issuance.issuer, + token_key_id: issuance.token_key_id, + tokens: [...issuance.tokens], + }); + } + savePool(path, pool); +} + +export function takeMatchingToken( + path: string, + match: { challengeDigest: Uint8Array; tokenKeyId?: Uint8Array }, +): { token: string; issuer: string; remaining: number } | null { + const pool = loadPool(path); + const expectedDigest = Buffer.from(match.challengeDigest); + const expectedKeyId = match.tokenKeyId + ? Buffer.from(match.tokenKeyId) + : undefined; + + for (const batch of pool.batches) { + if ( + expectedKeyId && + Buffer.from(batch.token_key_id, 'base64url').compare(expectedKeyId) !== 0 + ) { + continue; + } + for (let index = 0; index < batch.tokens.length; index++) { + const token = batch.tokens[index]; + if (token === undefined) { + continue; + } + const parsed = parsePooledToken(token); + if (!parsed) { + continue; + } + if (parsed.challengeDigest.compare(expectedDigest) !== 0) { + continue; + } + if (expectedKeyId && parsed.tokenKeyId.compare(expectedKeyId) !== 0) { + continue; + } + batch.tokens.splice(index, 1); + pool.batches = pool.batches.filter((entry) => entry.tokens.length > 0); + savePool(path, pool); + return { + token, + issuer: batch.issuer, + remaining: remainingCount(path), + }; + } + } + return null; +} diff --git a/packages/cli/src/commands/attestations/schema.ts b/packages/cli/src/commands/attestations/schema.ts index e2bba3a6..15d7fcb6 100644 --- a/packages/cli/src/commands/attestations/schema.ts +++ b/packages/cli/src/commands/attestations/schema.ts @@ -1,4 +1,5 @@ import { z } from 'incur'; +import { DEFAULT_AAT_POOL_PATH } from './pool'; export const requestOptions = z.object({ count: z.coerce @@ -17,4 +18,10 @@ export const requestOptions = z.object({ .describe( 'Access token. Defaults to the stored credentials from "link-cli auth login".', ), + poolFile: z + .string() + .default(DEFAULT_AAT_POOL_PATH) + .describe( + 'Local file that stores unused attestation tokens for identity request. Created if missing.', + ), }); diff --git a/packages/cli/src/commands/identity/index.tsx b/packages/cli/src/commands/identity/index.tsx index 05dca4c5..acfae696 100644 --- a/packages/cli/src/commands/identity/index.tsx +++ b/packages/cli/src/commands/identity/index.tsx @@ -10,12 +10,8 @@ import { createCredentialsCli } from '../credentials'; import { createRequestCli } from '../request'; export function createIdentityCli(options: { - createAttestationsResource: ( - accessToken?: string, - ) => IAttestationsResource; - createCredentialsResource: ( - accessToken?: string, - ) => ICredentialsResource; + createAttestationsResource: (accessToken?: string) => IAttestationsResource; + createCredentialsResource: (accessToken?: string) => ICredentialsResource; createWebBotAuthResource: () => IWebBotAuthResource; authStorage?: CliAuthStorage; envAccessToken?: string; diff --git a/packages/cli/src/commands/request/index.tsx b/packages/cli/src/commands/request/index.tsx index 591afe2e..f2f968fb 100644 --- a/packages/cli/src/commands/request/index.tsx +++ b/packages/cli/src/commands/request/index.tsx @@ -8,14 +8,6 @@ import { requireAuthGuard } from '../../utils/require-auth'; import { sanitizeDeep } from '../../utils/sanitize-text'; import { requestArgs, requestOptions } from './schema'; -function parseBody(body: string): unknown { - try { - return sanitizeDeep(JSON.parse(body)); - } catch { - return sanitizeDeep(body); - } -} - export function createRequestCli( createCredentialsResource: () => ICredentialsResource, createWebBotAuthResource: () => IWebBotAuthResource, @@ -24,7 +16,7 @@ export function createRequestCli( ) { return Cli.create('request', { description: - 'Make an HTTPS request. If the site asks who you are, present signed user info from Link.', + 'Make an HTTPS request. If the site asks for attestation or who you are, present a Link attestation token and signed user info.', args: requestArgs, options: requestOptions, alias: { method: 'X', data: 'd', header: 'H' }, @@ -36,229 +28,27 @@ export function createRequestCli( requireAuthGuard(c, authStorage, envAccessToken); const { url } = c.args; - const { claims, method, data, header, keyFile, keyType } = c.options; - - const { - buildPresentation, - buildRequestHeaders, - claimReferenceKey, - contentDigest, - formatClaimReference, - parseClaimList, - parseClaimsChallenge, - setRequestHeader, - supportsPreProvisionedPresentation, - } = await import('./present'); - - let headers: Record; - try { - headers = buildRequestHeaders(data, header); - } catch (error) { - return c.error({ - code: 'INVALID_INPUT', - message: (error as Error).message, - }); - } - - const httpMethod = ( - method ?? (data !== undefined ? 'POST' : 'GET') - ).toUpperCase(); - let target: URL; - try { - target = new URL(url); - const hostname = target.hostname.replace(/^\[|\]$/g, ''); - const loopback = - hostname === 'localhost' || - hostname === '::1' || - hostname.startsWith('127.'); - if ( - (target.protocol !== 'https:' && - !(target.protocol === 'http:' && loopback)) || - target.username || - target.password || - target.hash - ) { - throw new Error( - 'URL must be HTTPS (HTTP is allowed only for loopback development)', - ); - } - } catch (error) { - return c.error({ - code: 'INVALID_INPUT', - message: - error instanceof TypeError - ? `Invalid URL: ${url}` - : (error as Error).message, - }); - } - const send = (requestHeaders: Record) => - fetch(url, { - method: httpMethod, - body: data, - headers: requestHeaders, - redirect: 'manual', - }); - - // 1. Try without identity — most requests need nothing more. - const probe = await send(headers); - const probeBody = await probe.text(); - let challenge: ReturnType; - try { - challenge = parseClaimsChallenge(probe, probeBody); - } catch (error) { - return c.error({ - code: 'INVALID_CHALLENGE', - message: (error as Error).message, - }); - } - - if (!challenge) { - return { - url, - status: probe.status, - identity_required: false, - response: parseBody(probeBody), - }; - } - if (challenge.aud !== target.origin) { - return c.error({ - code: 'INVALID_CHALLENGE', - message: `Challenge audience ${challenge.aud} does not exactly match ${target.origin}.`, - }); - } - if (!supportsPreProvisionedPresentation(challenge)) { - return c.error({ - code: 'UNSUPPORTED_FORMAT', - message: - 'The verifier does not accept the supported dc+sd-jwt presentation format.', - }); - } - - // 2. The server asked who we are. Disclose what it asked for, unless the - // caller narrowed it with --claims. - const claimOverride = parseClaimList(claims); - if (claimOverride) { - const challenged = new Set( - challenge.claims.map((claim) => claimReferenceKey(claim)), - ); - const extra = claimOverride.filter( - (claim) => !challenged.has(claimReferenceKey(claim)), - ); - if (extra.length > 0) { - return c.error({ - code: 'INVALID_INPUT', - message: `--claims may only narrow the verifier request; not requested: ${extra.map(formatClaimReference).join(', ')}.`, - }); - } - } - const requested = claimOverride ?? challenge.claims; + const { claims, method, data, header, keyFile, keyType, poolFile } = + c.options; - const { issueCredential } = await import('../credentials/issue'); - const credential = await issueCredential({ - resource: createCredentialsResource(), - keyFile, - keyType, - }); - let credentialIssuerUrl: URL; - try { - credentialIssuerUrl = new URL(credential.issuer); - if (credentialIssuerUrl.protocol !== 'https:') { - throw new TypeError('Credential issuer must use HTTPS'); - } - } catch { - return c.error({ - code: 'INVALID_CREDENTIAL', - message: `Credential returned an invalid issuer: ${credential.issuer}.`, - }); - } - if (challenge.trusted_issuers) { - const trusted = challenge.trusted_issuers.some((issuer) => { - try { - return new URL(issuer).origin === credentialIssuerUrl.origin; - } catch { - return false; - } - }); - if (!trusted) { - return c.error({ - code: 'UNTRUSTED_ISSUER', - message: `The credential issuer ${credential.issuer} is not trusted by the verifier.`, - }); - } - } - - const presented = buildPresentation({ - credential: credential.credential, + const { runIdentityRequest } = await import('./run'); + const result = await runIdentityRequest({ + url, + claims, + method, + data, + header, keyFile, keyType, - aud: challenge.aud, - nonce: challenge.nonce, - disclose: requested, + poolFile, + createCredentialsResource, + createWebBotAuthResource, + sanitizeDeep, }); - - if (presented.unavailable.length > 0) { - return c.error({ - code: 'CLAIMS_UNAVAILABLE', - message: `The credential from ${credential.issuer} cannot disclose: ${presented.unavailable.map(formatClaimReference).join(', ')}. It holds: ${Object.keys(credential.claims).join(', ')}.`, - }); - } - - // 3. Bind the presentation and request body into a request-specific Web - // Bot Auth HTTP Message Signature, then retry. - const finalHeaders = { ...headers }; - setRequestHeader( - finalHeaders, - 'Identity-Presentation', - presented.presentation, - ); - if (data !== undefined) { - setRequestHeader(finalHeaders, 'Content-Digest', contentDigest(data)); + if (!result.ok) { + return c.error(result.error); } - const webBotAuth = await createWebBotAuthResource().signRequest({ - url, - method: httpMethod, - headers: finalHeaders, - ...(data !== undefined ? { body: data } : {}), - }); - setRequestHeader(finalHeaders, 'Signature', webBotAuth.signature); - setRequestHeader( - finalHeaders, - 'Signature-Input', - webBotAuth.signature_input, - ); - setRequestHeader( - finalHeaders, - 'Signature-Agent', - webBotAuth.signature_agent, - ); - const final = await send(finalHeaders); - const finalBody = await final.text(); - - return { - url, - status: final.status, - identity_required: true, - challenge: { - claims: challenge.claims, - aud: challenge.aud, - ...(challenge.purpose ? { purpose: challenge.purpose } : {}), - ...(challenge.trusted_issuers - ? { trusted_issuers: challenge.trusted_issuers } - : {}), - }, - identity: { - issuer: credential.issuer, - issuer_metadata: new URL( - '/.well-known/aap-issuer', - credentialIssuerUrl, - ).href, - disclosed: presented.disclosed, - withheld: presented.withheld, - credential_expires_at: credential.expires_at, - holder_key: credential.holder_key.path, - }, - response: parseBody(finalBody), - }; + return result.value; }, }); } diff --git a/packages/cli/src/commands/request/present.test.ts b/packages/cli/src/commands/request/present.test.ts index 543ff829..2c5951aa 100644 --- a/packages/cli/src/commands/request/present.test.ts +++ b/packages/cli/src/commands/request/present.test.ts @@ -65,6 +65,31 @@ describe('identity presentation', () => { ).toBeNull(); }); + it('recognizes Identity-Presentation next to a PrivateToken challenge', () => { + expect( + parseClaimsChallenge( + { + status: 401, + headers: new Headers({ + 'WWW-Authenticate': + 'PrivateToken challenge="AAEC", token-key="AAEC", max-age=300, Identity-Presentation', + 'Content-Type': 'application/problem+json', + }), + }, + JSON.stringify({ + type: 'urn:aap:claims-required', + aud: 'https://merchant.example', + nonce: 'n', + claims: ['email'], + formats: ['dc+sd-jwt'], + }), + ), + ).toMatchObject({ + aud: 'https://merchant.example', + claims: ['email'], + }); + }); + it('selects nested claim paths and uses the credential _sd_alg', () => { const email = encoded(['salt-email', 'email', 'a@example.com']); const street = encoded(['salt-street', 'street', 'Main Street']); diff --git a/packages/cli/src/commands/request/private-token.test.ts b/packages/cli/src/commands/request/private-token.test.ts new file mode 100644 index 00000000..d0d3f3a8 --- /dev/null +++ b/packages/cli/src/commands/request/private-token.test.ts @@ -0,0 +1,69 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { + authorizationHeader, + base64urlPad, + parsePrivateTokenChallenges, + parseWwwAuthenticate, +} from './private-token'; + +describe('PrivateToken challenge parsing', () => { + it('splits PrivateToken and Identity-Presentation from one header', () => { + const challenge = Buffer.from('challenge-bytes'); + const tokenKey = Buffer.from('token-key-bytes'); + const header = `PrivateToken challenge="${base64urlPad(challenge)}", token-key="${base64urlPad(tokenKey)}", max-age=300, Identity-Presentation`; + + expect(parseWwwAuthenticate(header)).toEqual([ + { + scheme: 'PrivateToken', + params: { + challenge: base64urlPad(challenge), + 'token-key': base64urlPad(tokenKey), + 'max-age': '300', + }, + }, + { scheme: 'Identity-Presentation', params: {} }, + ]); + }); + + it('parses a padded quoted PrivateToken challenge on a 401', () => { + const tokenType = Buffer.from([0x00, 0x02]); + const rest = Buffer.from('issuer.example'); + const challenge = Buffer.concat([tokenType, rest]); + const tokenKey = Buffer.alloc(32, 4); + const response = { + status: 401, + headers: new Headers({ + 'WWW-Authenticate': `PrivateToken challenge="${base64urlPad(challenge)}", token-key="${base64urlPad(tokenKey)}"`, + }), + }; + + expect(parsePrivateTokenChallenges(response)).toEqual([ + { + challenge: new Uint8Array(challenge), + tokenKey: new Uint8Array(tokenKey), + challengeDigest: new Uint8Array( + createHash('sha256').update(challenge).digest(), + ), + tokenKeyId: new Uint8Array( + createHash('sha256').update(tokenKey).digest(), + ), + }, + ]); + }); + + it('ignores non-401 responses', () => { + expect( + parsePrivateTokenChallenges({ + status: 402, + headers: new Headers({ + 'WWW-Authenticate': 'PrivateToken challenge="AAEC"', + }), + }), + ).toEqual([]); + }); + + it('quotes a padded token in the Authorization header', () => { + expect(authorizationHeader('abc')).toBe('PrivateToken token="abc="'); + }); +}); diff --git a/packages/cli/src/commands/request/private-token.ts b/packages/cli/src/commands/request/private-token.ts new file mode 100644 index 00000000..f5999551 --- /dev/null +++ b/packages/cli/src/commands/request/private-token.ts @@ -0,0 +1,184 @@ +import { createHash } from 'node:crypto'; + +const TOKEN_TYPE_BLIND_RSA = 0x0002; + +export interface PrivateTokenChallenge { + challenge: Uint8Array; + tokenKey?: Uint8Array; + maxAge?: number; + challengeDigest: Uint8Array; + tokenKeyId?: Uint8Array; +} + +interface WwwAuthenticateChallenge { + scheme: string; + params: Record; +} + +function skipWhitespace(input: string, start: number): number { + let index = start; + while (index < input.length && /\s/.test(input[index] ?? '')) { + index += 1; + } + return index; +} + +function isSchemeChar(char: string): boolean { + return /[A-Za-z0-9._+-]/.test(char); +} + +/** + * Splits WWW-Authenticate into challenges. A comma starts a new scheme when + * the following token is not an auth-param (`name=value`). + */ +export function parseWwwAuthenticate( + header: string, +): WwwAuthenticateChallenge[] { + const challenges: WwwAuthenticateChallenge[] = []; + let index = 0; + const length = header.length; + + while (index < length) { + index = skipWhitespace(header, index); + while (header[index] === ',') { + index = skipWhitespace(header, index + 1); + } + if (index >= length) { + break; + } + + const schemeStart = index; + while (index < length && isSchemeChar(header[index] ?? '')) { + index += 1; + } + const scheme = header.slice(schemeStart, index); + if (scheme.length === 0) { + break; + } + index = skipWhitespace(header, index); + + const paramStart = index; + while (index < length) { + const char = header[index]; + if (char === '"') { + index += 1; + while (index < length && header[index] !== '"') { + if (header[index] === '\\') { + index += 1; + } + index += 1; + } + if (index < length) { + index += 1; + } + continue; + } + if (char === ',') { + let lookahead = skipWhitespace(header, index + 1); + const nameStart = lookahead; + while (lookahead < length && isSchemeChar(header[lookahead] ?? '')) { + lookahead += 1; + } + lookahead = skipWhitespace(header, lookahead); + if (lookahead < length && header[lookahead] === '=') { + index += 1; + continue; + } + break; + } + index += 1; + } + + challenges.push({ + scheme, + params: parseAuthParams(header.slice(paramStart, index).trim()), + }); + } + + return challenges; +} + +function parseAuthParams(input: string): Record { + const params: Record = {}; + const pattern = /([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/g; + for (const match of input.matchAll(pattern)) { + const name = match[1]?.toLowerCase(); + if (!name) { + continue; + } + const quoted = match[2]; + const token = match[3]; + params[name] = + quoted !== undefined ? quoted.replace(/\\(.)/g, '$1') : (token ?? ''); + } + return params; +} + +function decodeBase64Url(value: string): Uint8Array { + return new Uint8Array(Buffer.from(value, 'base64url')); +} + +export function base64urlPad(value: Uint8Array | string): string { + const unpadded = + typeof value === 'string' + ? value.replace(/=+$/, '') + : Buffer.from(value).toString('base64url'); + const pad = (4 - (unpadded.length % 4)) % 4; + return `${unpadded}${'='.repeat(pad)}`; +} + +export function parsePrivateTokenChallenges( + response: Pick, +): PrivateTokenChallenge[] { + if (response.status !== 401) { + return []; + } + const header = response.headers.get('www-authenticate') ?? ''; + const challenges: PrivateTokenChallenge[] = []; + for (const entry of parseWwwAuthenticate(header)) { + if (entry.scheme.toLowerCase() !== 'privatetoken') { + continue; + } + if (!entry.params.challenge) { + throw new Error( + 'Invalid PrivateToken challenge: challenge parameter is missing', + ); + } + const challenge = decodeBase64Url(entry.params.challenge); + if (challenge.length < 2) { + throw new Error('Invalid PrivateToken challenge: challenge is too short'); + } + const tokenType = ((challenge[0] ?? 0) << 8) | (challenge[1] ?? 0); + if (tokenType !== TOKEN_TYPE_BLIND_RSA) { + throw new Error( + `Unsupported PrivateToken type 0x${tokenType.toString(16)}`, + ); + } + const tokenKey = entry.params['token-key'] + ? decodeBase64Url(entry.params['token-key']) + : undefined; + const maxAge = entry.params['max-age'] + ? Number(entry.params['max-age']) + : undefined; + challenges.push({ + challenge, + ...(tokenKey ? { tokenKey } : {}), + ...(maxAge !== undefined && Number.isFinite(maxAge) ? { maxAge } : {}), + challengeDigest: new Uint8Array( + createHash('sha256').update(challenge).digest(), + ), + ...(tokenKey + ? { + tokenKeyId: new Uint8Array( + createHash('sha256').update(tokenKey).digest(), + ), + } + : {}), + }); + } + return challenges; +} + +export function authorizationHeader(token: string): string { + return `PrivateToken token="${base64urlPad(token)}"`; +} diff --git a/packages/cli/src/commands/request/run.test.ts b/packages/cli/src/commands/request/run.test.ts new file mode 100644 index 00000000..10ba4fda --- /dev/null +++ b/packages/cli/src/commands/request/run.test.ts @@ -0,0 +1,118 @@ +import { createHash } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { saveIssuedTokens } from '../attestations/pool'; +import { base64urlPad } from './private-token'; +import { runIdentityRequest } from './run'; + +function encodeStableTokenChallenge(issuerName: string): Buffer { + const issuerBytes = Buffer.from(issuerName, 'utf-8'); + const challenge = Buffer.alloc(2 + 2 + issuerBytes.length + 1 + 2); + challenge.writeUInt16BE(0x0002, 0); + challenge.writeUInt16BE(issuerBytes.length, 2); + issuerBytes.copy(challenge, 4); + challenge.writeUInt8(0, 4 + issuerBytes.length); + challenge.writeUInt16BE(0, 5 + issuerBytes.length); + return challenge; +} + +function fakeToken(challengeDigest: Buffer, tokenKeyId: Buffer): string { + const raw = Buffer.alloc(2 + 32 + 32 + 32 + 8); + raw.writeUInt16BE(0x0002, 0); + challengeDigest.copy(raw, 34); + tokenKeyId.copy(raw, 66); + return raw.toString('base64url'); +} + +describe('runIdentityRequest PrivateToken retry', () => { + const directories: string[] = []; + + afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } + }); + + it('spends a pooled token and Web Bot Auth-signs the retry', async () => { + const directory = mkdtempSync(join(tmpdir(), 'link-cli-request-')); + directories.push(directory); + const poolFile = join(directory, 'aat-pool.json'); + const challenge = encodeStableTokenChallenge('api.link.com'); + const tokenKey = Buffer.alloc(32, 9); + const digest = createHash('sha256').update(challenge).digest(); + const tokenKeyId = createHash('sha256').update(tokenKey).digest(); + const token = fakeToken(digest, tokenKeyId); + saveIssuedTokens(poolFile, { + issuer: 'https://api.link.com', + token_key_id: tokenKeyId.toString('base64url'), + tokens: [token], + }); + + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response('attestation required', { + status: 401, + headers: { + 'WWW-Authenticate': `PrivateToken challenge="${base64urlPad(challenge)}", token-key="${base64urlPad(tokenKey)}", max-age=300`, + }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const signRequest = vi.fn(async () => ({ + signature: 'agent=:signature:', + signature_input: + 'agent=("@method" "@authority" "@path" "authorization" "signature-agent");created=1;expires=2;keyid="k";tag="web-bot-auth"', + signature_agent: + 'https://api.link.com/.well-known/http-message-signatures-directory', + authority: 'localhost:3000', + expires_at: '2099-01-01T00:00:00Z', + })); + + const result = await runIdentityRequest({ + url: 'http://localhost:3000/api/verified/contribute', + header: [], + keyFile: join(directory, 'holder.jwk'), + keyType: 'ed25519', + poolFile, + createCredentialsResource: () => { + throw new Error( + 'should not issue a credential for an AAT-only challenge', + ); + }, + createWebBotAuthResource: () => ({ signUrl: vi.fn(), signRequest }), + fetchImpl: fetchImpl as unknown as typeof fetch, + sanitizeDeep: (value) => value, + }); + + expect(result).toMatchObject({ + ok: true, + value: { + status: 200, + attestation_required: true, + identity_required: false, + attestation: { + issuer: 'https://api.link.com', + remaining_pool: 0, + }, + response: { ok: true }, + }, + }); + expect(signRequest).toHaveBeenCalledOnce(); + const retry = fetchImpl.mock.calls[1]?.[1] as RequestInit; + expect((retry.headers as Record).Authorization).toMatch( + /^PrivateToken token="/, + ); + expect((retry.headers as Record).Signature).toBe( + 'agent=:signature:', + ); + }); +}); diff --git a/packages/cli/src/commands/request/run.ts b/packages/cli/src/commands/request/run.ts new file mode 100644 index 00000000..83cba5cc --- /dev/null +++ b/packages/cli/src/commands/request/run.ts @@ -0,0 +1,404 @@ +import type { + ICredentialsResource, + IWebBotAuthResource, +} from '@stripe/link-sdk'; +import { remainingCount, takeMatchingToken } from '../attestations/pool'; +import type { HolderKeyType } from '../credentials/holder-key'; +import { issueCredential } from '../credentials/issue'; +import { + buildPresentation, + buildRequestHeaders, + claimReferenceKey, + contentDigest, + formatClaimReference, + parseClaimList, + parseClaimsChallenge, + setRequestHeader, + supportsPreProvisionedPresentation, +} from './present'; +import { + authorizationHeader, + parsePrivateTokenChallenges, +} from './private-token'; + +const MAX_CHALLENGE_ROUNDS = 3; + +export type IdentityRequestError = { + code: string; + message: string; +}; + +export type IdentityRequestResult = { + url: string; + status: number; + attestation_required: boolean; + identity_required: boolean; + challenge?: { + claims: unknown; + aud: string; + purpose?: string; + trusted_issuers?: string[]; + }; + attestation?: { + issuer: string; + remaining_pool: number; + }; + identity?: { + issuer: string; + issuer_metadata: string; + disclosed: unknown; + withheld: unknown; + credential_expires_at: string; + holder_key: string; + }; + response: unknown; +}; + +function parseBody( + body: string, + sanitizeDeep: (value: unknown) => unknown, +): unknown { + try { + return sanitizeDeep(JSON.parse(body)); + } catch { + return sanitizeDeep(body); + } +} + +export async function runIdentityRequest(options: { + url: string; + claims?: string; + method?: string; + data?: string; + header: string[]; + keyFile: string; + keyType: HolderKeyType; + poolFile: string; + createCredentialsResource: () => ICredentialsResource; + createWebBotAuthResource: () => IWebBotAuthResource; + fetchImpl?: typeof fetch; + sanitizeDeep: (value: unknown) => unknown; +}): Promise< + | { ok: true; value: IdentityRequestResult } + | { ok: false; error: IdentityRequestError } +> { + const { + url, + claims, + method, + data, + header, + keyFile, + keyType, + poolFile, + createCredentialsResource, + createWebBotAuthResource, + sanitizeDeep, + } = options; + const fetchImpl = options.fetchImpl ?? fetch; + + let headers: Record; + try { + headers = buildRequestHeaders(data, header); + } catch (error) { + return { + ok: false, + error: { code: 'INVALID_INPUT', message: (error as Error).message }, + }; + } + + const httpMethod = ( + method ?? (data !== undefined ? 'POST' : 'GET') + ).toUpperCase(); + let target: URL; + try { + target = new URL(url); + const hostname = target.hostname.replace(/^\[|\]$/g, ''); + const loopback = + hostname === 'localhost' || + hostname === '::1' || + hostname.startsWith('127.'); + if ( + (target.protocol !== 'https:' && + !(target.protocol === 'http:' && loopback)) || + target.username || + target.password || + target.hash + ) { + throw new Error( + 'URL must be HTTPS (HTTP is allowed only for loopback development)', + ); + } + } catch (error) { + return { + ok: false, + error: { + code: 'INVALID_INPUT', + message: + error instanceof TypeError + ? `Invalid URL: ${url}` + : (error as Error).message, + }, + }; + } + + const send = (requestHeaders: Record) => + fetchImpl(url, { + method: httpMethod, + body: data, + headers: requestHeaders, + redirect: 'manual', + }); + + const originalHeaders = { ...headers }; + let attestation: { issuer: string; remaining_pool: number } | undefined; + let identity: IdentityRequestResult['identity']; + let lastChallenge: IdentityRequestResult['challenge']; + let attestationRequired = false; + let identityRequired = false; + + for (let round = 0; round <= MAX_CHALLENGE_ROUNDS; round++) { + const response = await send(headers); + const body = await response.text(); + + let privateTokenChallenges: ReturnType; + let claimsChallenge: ReturnType; + try { + privateTokenChallenges = parsePrivateTokenChallenges(response); + claimsChallenge = parseClaimsChallenge(response, body); + } catch (error) { + return { + ok: false, + error: { + code: 'INVALID_CHALLENGE', + message: (error as Error).message, + }, + }; + } + + if (privateTokenChallenges.length === 0 && !claimsChallenge) { + return { + ok: true, + value: { + url, + status: response.status, + attestation_required: attestationRequired, + identity_required: identityRequired, + ...(lastChallenge ? { challenge: lastChallenge } : {}), + ...(attestation ? { attestation } : {}), + ...(identity ? { identity } : {}), + response: parseBody(body, sanitizeDeep), + }, + }; + } + + if (round === MAX_CHALLENGE_ROUNDS) { + return { + ok: false, + error: { + code: 'TOO_MANY_CHALLENGES', + message: + 'The verifier kept challenging after the maximum number of identity retries.', + }, + }; + } + + const nextHeaders = { ...originalHeaders }; + + if (privateTokenChallenges.length > 0) { + attestationRequired = true; + const spent = + privateTokenChallenges + .map((challenge) => + takeMatchingToken(poolFile, { + challengeDigest: challenge.challengeDigest, + ...(challenge.tokenKeyId + ? { tokenKeyId: challenge.tokenKeyId } + : {}), + }), + ) + .find((match) => match !== null) ?? null; + if (!spent) { + const empty = remainingCount(poolFile) === 0; + return { + ok: false, + error: empty + ? { + code: 'AAT_POOL_EMPTY', + message: + 'No attestation tokens in the local pool. Run "link-cli identity attestations request --count 10" first.', + } + : { + code: 'AAT_NO_MATCH', + message: + "The verifier's PrivateToken challenge does not match any token in the local pool.", + }, + }; + } + setRequestHeader( + nextHeaders, + 'Authorization', + authorizationHeader(spent.token), + ); + attestation = { + issuer: spent.issuer, + remaining_pool: spent.remaining, + }; + } + + if (claimsChallenge) { + identityRequired = true; + if (claimsChallenge.aud !== target.origin) { + return { + ok: false, + error: { + code: 'INVALID_CHALLENGE', + message: `Challenge audience ${claimsChallenge.aud} does not exactly match ${target.origin}.`, + }, + }; + } + if (!supportsPreProvisionedPresentation(claimsChallenge)) { + return { + ok: false, + error: { + code: 'UNSUPPORTED_FORMAT', + message: + 'The verifier does not accept the supported dc+sd-jwt presentation format.', + }, + }; + } + + const claimOverride = parseClaimList(claims); + if (claimOverride) { + const challenged = new Set( + claimsChallenge.claims.map((claim) => claimReferenceKey(claim)), + ); + const extra = claimOverride.filter( + (claim) => !challenged.has(claimReferenceKey(claim)), + ); + if (extra.length > 0) { + return { + ok: false, + error: { + code: 'INVALID_INPUT', + message: `--claims may only narrow the verifier request; not requested: ${extra.map(formatClaimReference).join(', ')}.`, + }, + }; + } + } + const requested = claimOverride ?? claimsChallenge.claims; + const credential = await issueCredential({ + resource: createCredentialsResource(), + keyFile, + keyType, + }); + let credentialIssuerUrl: URL; + try { + credentialIssuerUrl = new URL(credential.issuer); + if (credentialIssuerUrl.protocol !== 'https:') { + throw new TypeError('Credential issuer must use HTTPS'); + } + } catch { + return { + ok: false, + error: { + code: 'INVALID_CREDENTIAL', + message: `Credential returned an invalid issuer: ${credential.issuer}.`, + }, + }; + } + if (claimsChallenge.trusted_issuers) { + const trusted = claimsChallenge.trusted_issuers.some((issuer) => { + try { + return new URL(issuer).origin === credentialIssuerUrl.origin; + } catch { + return false; + } + }); + if (!trusted) { + return { + ok: false, + error: { + code: 'UNTRUSTED_ISSUER', + message: `The credential issuer ${credential.issuer} is not trusted by the verifier.`, + }, + }; + } + } + + const presented = buildPresentation({ + credential: credential.credential, + keyFile, + keyType, + aud: claimsChallenge.aud, + nonce: claimsChallenge.nonce, + disclose: requested, + }); + if (presented.unavailable.length > 0) { + return { + ok: false, + error: { + code: 'CLAIMS_UNAVAILABLE', + message: `The credential from ${credential.issuer} cannot disclose: ${presented.unavailable.map(formatClaimReference).join(', ')}. It holds: ${Object.keys(credential.claims).join(', ')}.`, + }, + }; + } + + setRequestHeader( + nextHeaders, + 'Identity-Presentation', + presented.presentation, + ); + lastChallenge = { + claims: claimsChallenge.claims, + aud: claimsChallenge.aud, + ...(claimsChallenge.purpose + ? { purpose: claimsChallenge.purpose } + : {}), + ...(claimsChallenge.trusted_issuers + ? { trusted_issuers: claimsChallenge.trusted_issuers } + : {}), + }; + identity = { + issuer: credential.issuer, + issuer_metadata: new URL('/.well-known/aap-issuer', credentialIssuerUrl) + .href, + disclosed: presented.disclosed, + withheld: presented.withheld, + credential_expires_at: credential.expires_at, + holder_key: credential.holder_key.path, + }; + } + + if (data !== undefined) { + setRequestHeader(nextHeaders, 'Content-Digest', contentDigest(data)); + } + const webBotAuth = await createWebBotAuthResource().signRequest({ + url, + method: httpMethod, + headers: nextHeaders, + ...(data !== undefined ? { body: data } : {}), + }); + setRequestHeader(nextHeaders, 'Signature', webBotAuth.signature); + setRequestHeader( + nextHeaders, + 'Signature-Input', + webBotAuth.signature_input, + ); + setRequestHeader( + nextHeaders, + 'Signature-Agent', + webBotAuth.signature_agent, + ); + headers = nextHeaders; + } + + return { + ok: false, + error: { + code: 'TOO_MANY_CHALLENGES', + message: + 'The verifier kept challenging after the maximum number of identity retries.', + }, + }; +} diff --git a/packages/cli/src/commands/request/schema.ts b/packages/cli/src/commands/request/schema.ts index 8184d094..a9c9bf3b 100644 --- a/packages/cli/src/commands/request/schema.ts +++ b/packages/cli/src/commands/request/schema.ts @@ -1,6 +1,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { z } from 'incur'; +import { DEFAULT_AAT_POOL_PATH } from '../attestations/pool'; export const requestArgs = z.object({ url: z @@ -17,6 +18,12 @@ export const requestOptions = z.object({ .describe( 'Comma-separated user-info fields to share, e.g. "email,given_name,family_name". Only these are sent. Defaults to what the site asked for.', ), + poolFile: z + .string() + .default(DEFAULT_AAT_POOL_PATH) + .describe( + 'Local file of unused attestation tokens from identity attestations request.', + ), method: z .string() .optional() diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 33f5d29f..79d96cd9 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -10,5 +10,10 @@ export { export * from './types/index'; export * from './resources/interfaces'; export * from './resources/attestations'; +export { + computeChallengeDigest, + encodeStableTokenChallenge, + parseFinalToken, +} from './resources/attestations-crypto'; export * from './resources/credentials'; export { getDuplicateSpendRequest } from './resources/spend-request'; diff --git a/packages/sdk/src/resources/__tests__/attestations-crypto.test.ts b/packages/sdk/src/resources/__tests__/attestations-crypto.test.ts index 88cc33bd..1044d014 100644 --- a/packages/sdk/src/resources/__tests__/attestations-crypto.test.ts +++ b/packages/sdk/src/resources/__tests__/attestations-crypto.test.ts @@ -1,6 +1,9 @@ -import { generateKeyPairSync, randomBytes } from 'node:crypto'; +import { createHash, generateKeyPairSync, randomBytes } from 'node:crypto'; import { + computeChallengeDigest, + encodeStableTokenChallenge, generateBlindedMessages, + parseFinalToken, unblindSignatures, } from '@/resources/attestations-crypto'; import { describe, expect, it } from 'vitest'; @@ -70,6 +73,36 @@ describe('Blind RSA finalization', () => { expect(tokens[0]?.raw).toHaveLength(2 + 32 + 32 + 32 + 128); }); + it('round-trips a stable TokenChallenge into the digest and parsed token', () => { + const digest = computeChallengeDigest(0x0002, 'api.link.com'); + expect(digest).toEqual( + new Uint8Array( + createHash('sha256') + .update(encodeStableTokenChallenge(0x0002, 'api.link.com')) + .digest(), + ), + ); + + const state = generateBlindedMessages(spki, 1, digest); + const token = state.tokens[0]; + expect(token).toBeDefined(); + if (!token) { + throw new Error('Expected one blinded token'); + } + const blindedMessage = bytesToBigInt(token.blindedMsg); + const blindSignature = bigIntToBytes( + modPow(blindedMessage, privateExponent, modulus), + token.blindedMsg.length, + ); + const tokens = unblindSignatures(state, [ + Buffer.from(blindSignature).toString('base64url'), + ]); + const parsed = parseFinalToken(tokens[0]!.base64url); + expect(parsed.tokenType).toBe(0x0002); + expect(parsed.challengeDigest).toEqual(digest); + expect(parsed.tokenKeyId).toEqual(state.tokenKeyId); + }); + it('rejects an invalid blind signature after unblinding', () => { const state = generateBlindedMessages( spki, diff --git a/packages/sdk/src/resources/attestations-crypto.ts b/packages/sdk/src/resources/attestations-crypto.ts index bef54e60..5c740b12 100644 --- a/packages/sdk/src/resources/attestations-crypto.ts +++ b/packages/sdk/src/resources/attestations-crypto.ts @@ -365,16 +365,16 @@ export function unblindSignatures( return results; } -export function computeChallengeDigest( +/** + * Stable TokenChallenge this profile pools against: fixed issuer_name, empty + * redemption_context, empty origin_info (RFC 9577). + */ +export function encodeStableTokenChallenge( tokenType: number, issuerName: string, ): Uint8Array { const issuerBytes = Buffer.from(issuerName, 'utf-8'); - const originBytes = Buffer.alloc(0); - - const challenge = Buffer.alloc( - 2 + 2 + issuerBytes.length + 1 + 2 + originBytes.length, - ); + const challenge = Buffer.alloc(2 + 2 + issuerBytes.length + 1 + 2); let offset = 0; challenge.writeUInt16BE(tokenType, offset); @@ -385,11 +385,52 @@ export function computeChallengeDigest( offset += issuerBytes.length; challenge.writeUInt8(0, offset); offset += 1; - challenge.writeUInt16BE(originBytes.length, offset); - offset += 2; - if (originBytes.length > 0) { - originBytes.copy(challenge, offset); - } + challenge.writeUInt16BE(0, offset); + + return new Uint8Array(challenge); +} + +export function computeChallengeDigest( + tokenType: number, + issuerName: string, +): Uint8Array { + return new Uint8Array( + createHash('sha256') + .update(encodeStableTokenChallenge(tokenType, issuerName)) + .digest(), + ); +} + +export interface ParsedFinalToken { + tokenType: number; + nonce: Uint8Array; + challengeDigest: Uint8Array; + tokenKeyId: Uint8Array; + authenticator: Uint8Array; + raw: Uint8Array; +} - return new Uint8Array(createHash('sha256').update(challenge).digest()); +export function parseFinalToken(token: string): ParsedFinalToken { + const raw = base64urlDecode(token); + const prefix = 2 + NONCE_SIZE + CHALLENGE_DIGEST_SIZE + TOKEN_KEY_ID_SIZE; + if (raw.length <= prefix) { + throw new Error('PrivateToken is too short'); + } + const tokenType = ((raw[0] ?? 0) << 8) | (raw[1] ?? 0); + if (tokenType !== TOKEN_TYPE) { + throw new Error( + `Unsupported PrivateToken type 0x${tokenType.toString(16)}`, + ); + } + return { + tokenType, + nonce: raw.slice(2, 2 + NONCE_SIZE), + challengeDigest: raw.slice( + 2 + NONCE_SIZE, + 2 + NONCE_SIZE + CHALLENGE_DIGEST_SIZE, + ), + tokenKeyId: raw.slice(2 + NONCE_SIZE + CHALLENGE_DIGEST_SIZE, prefix), + authenticator: raw.slice(prefix), + raw, + }; } From 9dc96e87313eacf227f30fa9c21aff11e6d17843 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Tue, 1 Sep 2026 20:14:22 -0400 Subject: [PATCH 21/26] feat: prepare identity headers without sending a request Committed-By-Agent: codex Co-authored-by: codex --- packages/cli/src/commands/request/run.test.ts | 95 ++++++++++++- packages/cli/src/commands/request/run.ts | 133 +++++++++++++----- packages/cli/src/commands/request/schema.ts | 5 +- 3 files changed, 191 insertions(+), 42 deletions(-) diff --git a/packages/cli/src/commands/request/run.test.ts b/packages/cli/src/commands/request/run.test.ts index 10ba4fda..61a6eea5 100644 --- a/packages/cli/src/commands/request/run.test.ts +++ b/packages/cli/src/commands/request/run.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { saveIssuedTokens } from '../attestations/pool'; +import { remainingCount, saveIssuedTokens } from '../attestations/pool'; import { base64urlPad } from './private-token'; import { runIdentityRequest } from './run'; @@ -115,4 +115,97 @@ describe('runIdentityRequest PrivateToken retry', () => { 'agent=:signature:', ); }); + + it('prepares an attestation header without sending the retry', async () => { + const directory = mkdtempSync(join(tmpdir(), 'link-cli-request-')); + directories.push(directory); + const poolFile = join(directory, 'aat-pool.json'); + const challenge = encodeStableTokenChallenge('api.link.com'); + const tokenKey = Buffer.alloc(32, 7); + const digest = createHash('sha256').update(challenge).digest(); + const tokenKeyId = createHash('sha256').update(tokenKey).digest(); + const token = fakeToken(digest, tokenKeyId); + saveIssuedTokens(poolFile, { + issuer: 'https://api.link.com', + token_key_id: tokenKeyId.toString('base64url'), + tokens: [token], + }); + const fetchImpl = vi.fn().mockResolvedValue( + new Response('attestation required', { + status: 401, + headers: { + 'WWW-Authenticate': `PrivateToken challenge="${base64urlPad(challenge)}", token-key="${base64urlPad(tokenKey)}"`, + }, + }), + ); + + const result = await runIdentityRequest({ + url: 'http://localhost:3000/api/verified/contribute', + header: [], + prepare: true, + keyFile: join(directory, 'holder.jwk'), + keyType: 'ed25519', + poolFile, + createCredentialsResource: () => { + throw new Error('should not issue a credential'); + }, + fetchImpl: fetchImpl as unknown as typeof fetch, + sanitizeDeep: (value) => value, + }); + + expect(result).toMatchObject({ + ok: true, + value: { + status: 401, + prepared_headers: { + attestation: expect.stringMatching(/^PrivateToken token="/), + }, + }, + }); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it('reuses a supplied attestation when preparing a fresh presentation', async () => { + const directory = mkdtempSync(join(tmpdir(), 'link-cli-request-')); + directories.push(directory); + const poolFile = join(directory, 'aat-pool.json'); + const challenge = encodeStableTokenChallenge('api.link.com'); + const tokenKey = Buffer.alloc(32, 5); + const digest = createHash('sha256').update(challenge).digest(); + const tokenKeyId = createHash('sha256').update(tokenKey).digest(); + saveIssuedTokens(poolFile, { + issuer: 'https://api.link.com', + token_key_id: tokenKeyId.toString('base64url'), + tokens: [fakeToken(digest, tokenKeyId)], + }); + const supplied = 'PrivateToken token="already-prepared"'; + const fetchImpl = vi.fn().mockResolvedValue( + new Response('attestation required', { + status: 401, + headers: { + 'WWW-Authenticate': `PrivateToken challenge="${base64urlPad(challenge)}", token-key="${base64urlPad(tokenKey)}"`, + }, + }), + ); + + const result = await runIdentityRequest({ + url: 'http://localhost:3000/api/verified/contribute', + header: [`Authorization: ${supplied}`], + prepare: true, + keyFile: join(directory, 'holder.jwk'), + keyType: 'ed25519', + poolFile, + createCredentialsResource: () => { + throw new Error('should not issue a credential'); + }, + fetchImpl: fetchImpl as unknown as typeof fetch, + sanitizeDeep: (value) => value, + }); + + expect(result).toMatchObject({ + ok: true, + value: { prepared_headers: { attestation: supplied } }, + }); + expect(remainingCount(poolFile)).toBe(1); + }); }); diff --git a/packages/cli/src/commands/request/run.ts b/packages/cli/src/commands/request/run.ts index 83cba5cc..683370f2 100644 --- a/packages/cli/src/commands/request/run.ts +++ b/packages/cli/src/commands/request/run.ts @@ -51,6 +51,10 @@ export type IdentityRequestResult = { credential_expires_at: string; holder_key: string; }; + prepared_headers?: { + attestation?: string; + identity_presentation?: string; + }; response: unknown; }; @@ -65,17 +69,29 @@ function parseBody( } } +function requestHeader( + headers: Record, + name: string, +): string | undefined { + const entry = Object.entries(headers).find( + ([candidate]) => candidate.toLowerCase() === name.toLowerCase(), + ); + return entry?.[1]; +} + export async function runIdentityRequest(options: { url: string; claims?: string; method?: string; data?: string; header: string[]; + prepare?: boolean; keyFile: string; keyType: HolderKeyType; poolFile: string; createCredentialsResource: () => ICredentialsResource; - createWebBotAuthResource: () => IWebBotAuthResource; + createWebBotAuthResource?: () => IWebBotAuthResource; + initialResponse?: { response: Response; body: string }; fetchImpl?: typeof fetch; sanitizeDeep: (value: unknown) => unknown; }): Promise< @@ -88,6 +104,7 @@ export async function runIdentityRequest(options: { method, data, header, + prepare = false, keyFile, keyType, poolFile, @@ -158,8 +175,9 @@ export async function runIdentityRequest(options: { let identityRequired = false; for (let round = 0; round <= MAX_CHALLENGE_ROUNDS; round++) { - const response = await send(headers); - const body = await response.text(); + const supplied = round === 0 ? options.initialResponse : undefined; + const response = supplied?.response ?? (await send(headers)); + const body = supplied?.body ?? (await response.text()); let privateTokenChallenges: ReturnType; let claimsChallenge: ReturnType; @@ -207,43 +225,51 @@ export async function runIdentityRequest(options: { if (privateTokenChallenges.length > 0) { attestationRequired = true; - const spent = - privateTokenChallenges - .map((challenge) => - takeMatchingToken(poolFile, { - challengeDigest: challenge.challengeDigest, - ...(challenge.tokenKeyId - ? { tokenKeyId: challenge.tokenKeyId } - : {}), - }), - ) - .find((match) => match !== null) ?? null; - if (!spent) { - const empty = remainingCount(poolFile) === 0; - return { - ok: false, - error: empty - ? { - code: 'AAT_POOL_EMPTY', - message: - 'No attestation tokens in the local pool. Run "link-cli identity attestations request --count 10" first.', - } - : { - code: 'AAT_NO_MATCH', - message: - "The verifier's PrivateToken challenge does not match any token in the local pool.", - }, + const suppliedAttestation = requestHeader( + originalHeaders, + 'authorization', + ); + if (suppliedAttestation && /^PrivateToken\s/i.test(suppliedAttestation)) { + setRequestHeader(nextHeaders, 'Authorization', suppliedAttestation); + } else { + const spent = + privateTokenChallenges + .map((challenge) => + takeMatchingToken(poolFile, { + challengeDigest: challenge.challengeDigest, + ...(challenge.tokenKeyId + ? { tokenKeyId: challenge.tokenKeyId } + : {}), + }), + ) + .find((match) => match !== null) ?? null; + if (!spent) { + const empty = remainingCount(poolFile) === 0; + return { + ok: false, + error: empty + ? { + code: 'AAT_POOL_EMPTY', + message: + 'No attestation tokens in the local pool. Run "link-cli identity attestations request --count 10" first.', + } + : { + code: 'AAT_NO_MATCH', + message: + "The verifier's PrivateToken challenge does not match any token in the local pool.", + }, + }; + } + setRequestHeader( + nextHeaders, + 'Authorization', + authorizationHeader(spent.token), + ); + attestation = { + issuer: spent.issuer, + remaining_pool: spent.remaining, }; } - setRequestHeader( - nextHeaders, - 'Authorization', - authorizationHeader(spent.token), - ); - attestation = { - issuer: spent.issuer, - remaining_pool: spent.remaining, - }; } if (claimsChallenge) { @@ -370,6 +396,37 @@ export async function runIdentityRequest(options: { }; } + if (prepare) { + return { + ok: true, + value: { + url, + status: response.status, + attestation_required: attestationRequired, + identity_required: identityRequired, + ...(lastChallenge ? { challenge: lastChallenge } : {}), + ...(attestation ? { attestation } : {}), + ...(identity ? { identity } : {}), + prepared_headers: { + ...(nextHeaders.Authorization + ? { attestation: nextHeaders.Authorization } + : {}), + ...(nextHeaders['Identity-Presentation'] + ? { + identity_presentation: nextHeaders['Identity-Presentation'], + } + : {}), + }, + response: parseBody(body, sanitizeDeep), + }, + }; + } + + if (!createWebBotAuthResource) { + throw new Error( + 'A Web Bot Auth resource is required when sending an identity request', + ); + } if (data !== undefined) { setRequestHeader(nextHeaders, 'Content-Digest', contentDigest(data)); } diff --git a/packages/cli/src/commands/request/schema.ts b/packages/cli/src/commands/request/schema.ts index a9c9bf3b..e4b8db79 100644 --- a/packages/cli/src/commands/request/schema.ts +++ b/packages/cli/src/commands/request/schema.ts @@ -1,7 +1,6 @@ -import { homedir } from 'node:os'; -import { join } from 'node:path'; import { z } from 'incur'; import { DEFAULT_AAT_POOL_PATH } from '../attestations/pool'; +import { DEFAULT_HOLDER_KEY_PATH } from '../credentials/holder-key'; export const requestArgs = z.object({ url: z @@ -38,7 +37,7 @@ export const requestOptions = z.object({ .describe('Request header in "Name: Value" format (repeatable)'), keyFile: z .string() - .default(join(homedir(), '.link', 'holder-key.jwk')) + .default(DEFAULT_HOLDER_KEY_PATH) .describe( 'Path to a local key file. Created if missing. Reuse the same file used by identity credentials get.', ), From 0951b97d5856a4b53fefe73681f7676753a0f162 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Tue, 1 Sep 2026 20:21:07 -0400 Subject: [PATCH 22/26] chore: align plugin metadata with CLI 0.14.0 Committed-By-Agent: codex Co-authored-by: codex --- plugins/link/.claude-plugin/plugin.json | 2 +- plugins/link/.codex-plugin/plugin.json | 2 +- plugins/link/.cursor-plugin/plugin.json | 18 +++++++++++++++--- skills/create-payment-credential/SKILL.md | 2 +- skills/financial-insights/SKILL.md | 2 +- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/plugins/link/.claude-plugin/plugin.json b/plugins/link/.claude-plugin/plugin.json index 4a900ab9..574fa9fa 100644 --- a/plugins/link/.claude-plugin/plugin.json +++ b/plugins/link/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "link", - "version": "0.11.0", + "version": "0.14.0", "description": "Authenticate with Link, create spend requests, and retrieve one-time-use card or shared payment token credentials for user-approved purchases.", "author": { "name": "Stripe" diff --git a/plugins/link/.codex-plugin/plugin.json b/plugins/link/.codex-plugin/plugin.json index 55b6d870..d7ea4b46 100644 --- a/plugins/link/.codex-plugin/plugin.json +++ b/plugins/link/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "link", - "version": "0.11.0", + "version": "0.14.0", "description": "Secure, one-time-use payment credentials from Link", "author": { "name": "Stripe", diff --git a/plugins/link/.cursor-plugin/plugin.json b/plugins/link/.cursor-plugin/plugin.json index 50819fc0..530c1128 100644 --- a/plugins/link/.cursor-plugin/plugin.json +++ b/plugins/link/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "link", "displayName": "Stripe Link", - "version": "0.11.0", + "version": "0.14.0", "description": "Get secure, one-time-use payment credentials from a Link wallet so agents can complete purchases on your behalf.", "author": { "name": "Stripe" @@ -9,9 +9,21 @@ "homepage": "https://link.com/agents", "repository": "https://github.com/stripe/link-cli", "license": "MIT", - "keywords": ["payment", "link", "stripe", "agentic-commerce", "mcp"], + "keywords": [ + "payment", + "link", + "stripe", + "agentic-commerce", + "mcp" + ], "category": "payments", - "tags": ["payments", "stripe", "link", "agents", "mcp"], + "tags": [ + "payments", + "stripe", + "link", + "agents", + "mcp" + ], "skills": "./skills/", "mcpServers": "./.mcp.json" } diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index f8afe8de..f827eb89 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -1,5 +1,5 @@ --- -version: 0.11.0 +version: 0.14.0 name: create-payment-credential description: | Gets secure, one-time-use payment credentials (cards, tokens) from a Link wallet so agents can complete purchases on behalf of users. Use when the user says "get me a card", "buy something", "pay for X", "make a purchase", "I need to pay", "complete checkout", or asks to transact on any merchant site. Use when the user asks to connect or log in to or sign up for their Link account. diff --git a/skills/financial-insights/SKILL.md b/skills/financial-insights/SKILL.md index 614d9494..acce677d 100644 --- a/skills/financial-insights/SKILL.md +++ b/skills/financial-insights/SKILL.md @@ -1,5 +1,5 @@ --- -version: 0.11.0 +version: 0.14.0 name: financial-insights description: | Reads a user's Link financial data — transactions, balances, and wallet sources — so agents can answer questions about spending and available source capabilities. Use when the user says "check my balance", "how much did I spend", "show my transactions", "what accounts are connected", "summarize my spending", "recent purchases", or asks about their financial activity, account balances, or linked sources. From ef529d5a3c37899ad2ba94acbb0d0cd33843e141 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Mon, 24 Aug 2026 23:44:40 -0400 Subject: [PATCH 23/26] Honor Payment-Authorization in mpp pay. When a 402 challenge advertises header="Payment-Authorization", send the Payment credential in that field so ordinary Authorization can coexist. Co-authored-by: Cursor Committed-By-Agent: cursor --- .changeset/payment-authorization-header.md | 5 ++ packages/cli/src/__tests__/cli.test.ts | 47 ++++++++++- .../commands/mpp/credential-header.test.ts | 83 +++++++++++++++++++ .../cli/src/commands/mpp/credential-header.ts | 70 ++++++++++++++++ packages/cli/src/commands/mpp/decode.test.ts | 21 +++++ packages/cli/src/commands/mpp/decode.ts | 13 +++ packages/cli/src/commands/mpp/pay.tsx | 38 +++++++-- 7 files changed, 269 insertions(+), 8 deletions(-) create mode 100644 .changeset/payment-authorization-header.md create mode 100644 packages/cli/src/commands/mpp/credential-header.test.ts create mode 100644 packages/cli/src/commands/mpp/credential-header.ts diff --git a/.changeset/payment-authorization-header.md b/.changeset/payment-authorization-header.md new file mode 100644 index 00000000..d4a89d6e --- /dev/null +++ b/.changeset/payment-authorization-header.md @@ -0,0 +1,5 @@ +--- +"@stripe/link-cli": patch +--- + +Honor advertised Payment-Authorization headers in `mpp pay` so Payment credentials can coexist with ordinary Authorization diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index e042b836..2df10299 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -2448,7 +2448,7 @@ describe('production mode', () => { ].join(' '); function decodeCredential(authorizationHeader: string): { - challenge: { intent: string }; + challenge: { intent: string; header?: string }; payload: Record; } { const encoded = authorizationHeader.replace(/^Payment\s+/i, ''); @@ -2483,6 +2483,51 @@ describe('production mode', () => { expect(merchantRequests[1].headers.authorization).toMatch(/^Payment /); }); + it('retries with Payment-Authorization when the challenge advertises that header', async () => { + const wwwAuthenticate = [ + 'Payment id="ch_001",', + 'realm="127.0.0.1",', + 'method="stripe",', + 'intent="charge",', + 'header="Payment-Authorization",', + `request="${Buffer.from(JSON.stringify({ networkId: 'net_001', amount: '1000', currency: 'usd', decimals: 2, paymentMethodTypes: ['card'] })).toString('base64')}",`, + 'expires="2099-01-01T00:00:00Z"', + ].join(' '); + + setNextResponse(200, APPROVED_SPT_REQUEST); + setMerchantResponse(402, '{"error":"payment required"}', { + 'www-authenticate': wwwAuthenticate, + }); + setMerchantResponse(200, '{"success":true}'); + + const result = await runProdCli( + 'mpp', + 'pay', + `http://127.0.0.1:${merchantPort}/api/charge`, + '--spend-request-id', + 'lsrq_spt_001', + '--header', + 'Authorization: Bearer app-token', + '--json', + ); + + expect(result.exitCode).toBe(0); + expect(merchantRequests).toHaveLength(2); + expect(merchantRequests[1].headers.authorization).toBe( + 'Bearer app-token', + ); + expect(merchantRequests[1].headers['payment-authorization']).toMatch( + /^Payment /, + ); + const credential = decodeCredential( + merchantRequests[1].headers['payment-authorization'] as string, + ); + expect(credential.challenge).toMatchObject({ + intent: 'charge', + header: 'Payment-Authorization', + }); + }); + it('returns structured response when the paid retry fails', async () => { setNextResponse(200, APPROVED_SPT_REQUEST); setMerchantResponse(402, '{"error":"payment required"}', { diff --git a/packages/cli/src/commands/mpp/credential-header.test.ts b/packages/cli/src/commands/mpp/credential-header.test.ts new file mode 100644 index 00000000..424690a7 --- /dev/null +++ b/packages/cli/src/commands/mpp/credential-header.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_CREDENTIAL_HEADER, + PAYMENT_AUTHORIZATION_HEADER, + canonicalizeCredentialHeader, + resolvePaymentCredentialHeader, + shouldEchoCredentialHeader, +} from './credential-header'; + +const STRIPE_REQUEST = Buffer.from( + JSON.stringify({ + amount: '1000', + currency: 'usd', + methodDetails: { networkId: 'net_001', paymentMethodTypes: ['card'] }, + }), +).toString('base64'); + +describe('resolvePaymentCredentialHeader', () => { + it('defaults to Authorization when the challenge omits header', () => { + const wwwAuthenticate = [ + 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', + `request="${STRIPE_REQUEST}"`, + ].join(' '); + + expect(resolvePaymentCredentialHeader(wwwAuthenticate, 'ch_001')).toBe( + DEFAULT_CREDENTIAL_HEADER, + ); + }); + + it('uses Payment-Authorization when the stripe challenge advertises it', () => { + const wwwAuthenticate = [ + 'Payment id="tempo_001", realm="merchant.example", method="tempo", intent="charge", request="e30=",', + 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', + 'header="Payment-Authorization",', + `request="${STRIPE_REQUEST}"`, + ].join(' '); + + expect(resolvePaymentCredentialHeader(wwwAuthenticate, 'ch_001')).toBe( + PAYMENT_AUTHORIZATION_HEADER, + ); + }); + + it('does not inherit header from a different Payment challenge', () => { + const wwwAuthenticate = [ + 'Payment id="tempo_001", realm="merchant.example", method="tempo", intent="charge",', + 'header="Payment-Authorization", request="e30=",', + 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', + `request="${STRIPE_REQUEST}"`, + ].join(' '); + + expect(resolvePaymentCredentialHeader(wwwAuthenticate, 'ch_001')).toBe( + DEFAULT_CREDENTIAL_HEADER, + ); + }); + + it('rejects an unsupported advertised header', () => { + const wwwAuthenticate = [ + 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', + 'header="X-Payment",', + `request="${STRIPE_REQUEST}"`, + ].join(' '); + + expect(() => + resolvePaymentCredentialHeader(wwwAuthenticate, 'ch_001'), + ).toThrow(/Unsupported payment credential header/i); + }); +}); + +describe('canonicalizeCredentialHeader', () => { + it('treats omitted and Authorization values as the default', () => { + expect(canonicalizeCredentialHeader(undefined)).toBe( + DEFAULT_CREDENTIAL_HEADER, + ); + expect(canonicalizeCredentialHeader('authorization')).toBe( + DEFAULT_CREDENTIAL_HEADER, + ); + }); + + it('echoes only non-default headers', () => { + expect(shouldEchoCredentialHeader(DEFAULT_CREDENTIAL_HEADER)).toBe(false); + expect(shouldEchoCredentialHeader(PAYMENT_AUTHORIZATION_HEADER)).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/mpp/credential-header.ts b/packages/cli/src/commands/mpp/credential-header.ts new file mode 100644 index 00000000..d9b98ec9 --- /dev/null +++ b/packages/cli/src/commands/mpp/credential-header.ts @@ -0,0 +1,70 @@ +export const DEFAULT_CREDENTIAL_HEADER = 'Authorization'; +export const PAYMENT_AUTHORIZATION_HEADER = 'Payment-Authorization'; + +/** + * HTTP field the client must use for the Payment credential. + * + * mppx 0.8.x drops unknown WWW-Authenticate auth-params (including `header`) + * when parsing challenges, so this reads `header` from the raw challenge + * string. Omitted `header` defaults to Authorization. The only advertised + * alternate this CLI supports is Payment-Authorization. + */ +export function resolvePaymentCredentialHeader( + wwwAuthenticate: string, + challengeId: string, +): string { + const chunk = paymentSchemeChunks(wwwAuthenticate).find( + (scheme) => authParam(scheme, 'id') === challengeId, + ); + return canonicalizeCredentialHeader( + chunk ? authParam(chunk, 'header') : undefined, + ); +} + +export function canonicalizeCredentialHeader( + value: string | undefined, +): string { + if (value == null || value === '') { + return DEFAULT_CREDENTIAL_HEADER; + } + if (equalsHeaderName(value, DEFAULT_CREDENTIAL_HEADER)) { + return DEFAULT_CREDENTIAL_HEADER; + } + if (equalsHeaderName(value, PAYMENT_AUTHORIZATION_HEADER)) { + return PAYMENT_AUTHORIZATION_HEADER; + } + throw new Error( + `Unsupported payment credential header "${value}". Only Authorization (default) and Payment-Authorization are supported.`, + ); +} + +export function shouldEchoCredentialHeader(header: string): boolean { + return !equalsHeaderName(header, DEFAULT_CREDENTIAL_HEADER); +} + +function equalsHeaderName(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase(); +} + +function paymentSchemeChunks(wwwAuthenticate: string): string[] { + const starts: number[] = []; + for (const match of wwwAuthenticate.matchAll(/Payment\s+/gi)) { + if (match.index !== undefined) starts.push(match.index); + } + return starts.map((start, index) => { + const nextStart = starts[index + 1]; + const end = nextStart === undefined ? wwwAuthenticate.length : nextStart; + return wwwAuthenticate.slice(start, end).replace(/,\s*$/, ''); + }); +} + +function authParam(chunk: string, name: string): string | undefined { + const pattern = new RegExp( + `(?:^|[,\\s])${name}\\s*=\\s*(?:"((?:\\\\.|[^"\\\\])*)"|([^,\\s]+))`, + 'i', + ); + const match = chunk.match(pattern); + if (!match) return undefined; + if (match[1] !== undefined) return match[1].replace(/\\(.)/g, '$1'); + return match[2]; +} diff --git a/packages/cli/src/commands/mpp/decode.test.ts b/packages/cli/src/commands/mpp/decode.test.ts index ca5a102d..265d427b 100644 --- a/packages/cli/src/commands/mpp/decode.test.ts +++ b/packages/cli/src/commands/mpp/decode.test.ts @@ -35,6 +35,27 @@ describe('decodeStripeChallenge', () => { }); }); + it('includes header when the stripe challenge advertises Payment-Authorization', () => { + const header = [ + 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', + 'header="Payment-Authorization",', + `request="${encodeRequest({ + amount: '1000', + currency: 'usd', + methodDetails: { + networkId: 'net_001', + paymentMethodTypes: ['card'], + }, + })}"`, + ].join(' '); + + expect(decodeStripeChallenge(header)).toMatchObject({ + id: 'ch_001', + header: 'Payment-Authorization', + network_id: 'net_001', + }); + }); + it('handles escaped quoted-string values in challenge parameters', () => { const header = [ 'Payment id="ch_001",', diff --git a/packages/cli/src/commands/mpp/decode.ts b/packages/cli/src/commands/mpp/decode.ts index 55d4c21d..fa5174d3 100644 --- a/packages/cli/src/commands/mpp/decode.ts +++ b/packages/cli/src/commands/mpp/decode.ts @@ -1,5 +1,9 @@ import { Challenge } from 'mppx'; import { sanitizeDeep } from '../../utils/sanitize-text'; +import { + resolvePaymentCredentialHeader, + shouldEchoCredentialHeader, +} from './credential-header'; type StripeChargeChallenge = Challenge.Challenge< Record, @@ -21,6 +25,8 @@ export interface DecodedStripeChallenge { description?: string; digest?: string; expires?: string; + /** Present only when the challenge advertised a non-default credential field. */ + header?: string; network_id: string; request_json: Record; } @@ -118,6 +124,10 @@ export function decodeStripeChallenge( const { challenge, networkId, request } = resolveStripeChallenge( Challenge.deserializeList(challengeHeader), ); + const credentialHeader = resolvePaymentCredentialHeader( + challengeHeader, + challenge.id, + ); return sanitizeDeep({ id: challenge.id, @@ -127,6 +137,9 @@ export function decodeStripeChallenge( description: challenge.description, digest: challenge.digest, expires: challenge.expires, + ...(shouldEchoCredentialHeader(credentialHeader) + ? { header: credentialHeader } + : {}), network_id: networkId, request_json: request, }); diff --git a/packages/cli/src/commands/mpp/pay.tsx b/packages/cli/src/commands/mpp/pay.tsx index 0bb03062..22df1916 100644 --- a/packages/cli/src/commands/mpp/pay.tsx +++ b/packages/cli/src/commands/mpp/pay.tsx @@ -10,6 +10,10 @@ import { Methods as StripeMethods } from 'mppx/stripe'; import React, { useEffect, useState } from 'react'; import { pollUntilApproved } from '../../utils/poll-until-approved'; import { sanitizeDeep } from '../../utils/sanitize-text'; +import { + resolvePaymentCredentialHeader, + shouldEchoCredentialHeader, +} from './credential-header'; import { decodeStripeChallenge, getStripeChargeChallengeFromResponse, @@ -52,11 +56,19 @@ export async function readPayResult(response: Response): Promise { }); } -function createStripePaymentClient(spt: string) { +function withAdvertisedHeader( + challenge: T, + credentialHeader: string, +): T { + if (!shouldEchoCredentialHeader(credentialHeader)) return challenge; + return { ...challenge, header: credentialHeader } as T; +} + +function createStripePaymentClient(spt: string, credentialHeader: string) { const stripeCharge = Method.toClient(StripeMethods.charge, { async createCredential({ challenge }) { return Credential.serialize({ - challenge, + challenge: withAdvertisedHeader(challenge, credentialHeader), payload: { spt }, }); }, @@ -67,7 +79,7 @@ function createStripePaymentClient(spt: string) { { async createCredential({ challenge }) { return Credential.serialize({ - challenge, + challenge: withAdvertisedHeader(challenge, credentialHeader), payload: { action: 'open', grantedToken: spt }, }); }, @@ -87,7 +99,7 @@ function createStripePaymentClient(spt: string) { }, setCredential(request, credential) { const nextHeaders = new Headers(request.headers); - nextHeaders.set('Authorization', credential); + nextHeaders.set(credentialHeader, credential); return { ...request, headers: nextHeaders }; }, }), @@ -168,15 +180,27 @@ export async function payWithSpt( return readPayResult(initialResponse); } - const authHeader = - await createStripePaymentClient(spt).createCredential(initialResponse); + const wwwAuthenticate = initialResponse.headers.get('www-authenticate'); + if (!wwwAuthenticate) { + throw new Error('URL returned 402 but no WWW-Authenticate header'); + } + + const challenge = getStripeChargeChallengeFromResponse(initialResponse); + const credentialHeader = resolvePaymentCredentialHeader( + wwwAuthenticate, + challenge.id, + ); + const credential = await createStripePaymentClient( + spt, + credentialHeader, + ).createCredential(initialResponse); const retryResponse = await fetch(url, { method: httpMethod, body: data, headers: { ...requestHeaders, - Authorization: authHeader, + [credentialHeader]: credential, }, }); From 5e98e9a12e14736084ad3e3359ac10ccac992d41 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Tue, 25 Aug 2026 11:48:27 -0400 Subject: [PATCH 24/26] Restrict mpp pay credentials to Authorization or Payment-Authorization. The protocol no longer allows arbitrary credential header names, so send and echo only those two fields. Co-authored-by: Cursor Committed-By-Agent: cursor --- .changeset/payment-authorization-header.md | 2 +- .../cli/src/commands/mpp/credential-header.ts | 18 +++++++---- packages/cli/src/commands/mpp/decode.ts | 2 +- packages/cli/src/commands/mpp/pay.tsx | 32 +++++++++++++++---- 4 files changed, 39 insertions(+), 15 deletions(-) diff --git a/.changeset/payment-authorization-header.md b/.changeset/payment-authorization-header.md index d4a89d6e..19f881db 100644 --- a/.changeset/payment-authorization-header.md +++ b/.changeset/payment-authorization-header.md @@ -2,4 +2,4 @@ "@stripe/link-cli": patch --- -Honor advertised Payment-Authorization headers in `mpp pay` so Payment credentials can coexist with ordinary Authorization +Honor Payment-Authorization in `mpp pay` so Payment credentials can coexist with ordinary Authorization. Challenges may select only Authorization (default) or Payment-Authorization. diff --git a/packages/cli/src/commands/mpp/credential-header.ts b/packages/cli/src/commands/mpp/credential-header.ts index d9b98ec9..2af1658e 100644 --- a/packages/cli/src/commands/mpp/credential-header.ts +++ b/packages/cli/src/commands/mpp/credential-header.ts @@ -1,18 +1,22 @@ export const DEFAULT_CREDENTIAL_HEADER = 'Authorization'; export const PAYMENT_AUTHORIZATION_HEADER = 'Payment-Authorization'; +export type PaymentCredentialHeader = + | typeof DEFAULT_CREDENTIAL_HEADER + | typeof PAYMENT_AUTHORIZATION_HEADER; + /** * HTTP field the client must use for the Payment credential. * * mppx 0.8.x drops unknown WWW-Authenticate auth-params (including `header`) * when parsing challenges, so this reads `header` from the raw challenge - * string. Omitted `header` defaults to Authorization. The only advertised - * alternate this CLI supports is Payment-Authorization. + * string. The protocol only allows Authorization (omitted / default) or + * Payment-Authorization. */ export function resolvePaymentCredentialHeader( wwwAuthenticate: string, challengeId: string, -): string { +): PaymentCredentialHeader { const chunk = paymentSchemeChunks(wwwAuthenticate).find( (scheme) => authParam(scheme, 'id') === challengeId, ); @@ -23,7 +27,7 @@ export function resolvePaymentCredentialHeader( export function canonicalizeCredentialHeader( value: string | undefined, -): string { +): PaymentCredentialHeader { if (value == null || value === '') { return DEFAULT_CREDENTIAL_HEADER; } @@ -38,8 +42,10 @@ export function canonicalizeCredentialHeader( ); } -export function shouldEchoCredentialHeader(header: string): boolean { - return !equalsHeaderName(header, DEFAULT_CREDENTIAL_HEADER); +export function shouldEchoCredentialHeader( + header: PaymentCredentialHeader, +): boolean { + return header === PAYMENT_AUTHORIZATION_HEADER; } function equalsHeaderName(left: string, right: string): boolean { diff --git a/packages/cli/src/commands/mpp/decode.ts b/packages/cli/src/commands/mpp/decode.ts index fa5174d3..25d570b3 100644 --- a/packages/cli/src/commands/mpp/decode.ts +++ b/packages/cli/src/commands/mpp/decode.ts @@ -25,7 +25,7 @@ export interface DecodedStripeChallenge { description?: string; digest?: string; expires?: string; - /** Present only when the challenge advertised a non-default credential field. */ + /** Present only when the challenge advertised Payment-Authorization. */ header?: string; network_id: string; request_json: Record; diff --git a/packages/cli/src/commands/mpp/pay.tsx b/packages/cli/src/commands/mpp/pay.tsx index 22df1916..07c0a4a3 100644 --- a/packages/cli/src/commands/mpp/pay.tsx +++ b/packages/cli/src/commands/mpp/pay.tsx @@ -11,6 +11,9 @@ import React, { useEffect, useState } from 'react'; import { pollUntilApproved } from '../../utils/poll-until-approved'; import { sanitizeDeep } from '../../utils/sanitize-text'; import { + DEFAULT_CREDENTIAL_HEADER, + PAYMENT_AUTHORIZATION_HEADER, + type PaymentCredentialHeader, resolvePaymentCredentialHeader, shouldEchoCredentialHeader, } from './credential-header'; @@ -58,13 +61,28 @@ export async function readPayResult(response: Response): Promise { function withAdvertisedHeader( challenge: T, - credentialHeader: string, + credentialHeader: PaymentCredentialHeader, ): T { if (!shouldEchoCredentialHeader(credentialHeader)) return challenge; return { ...challenge, header: credentialHeader } as T; } -function createStripePaymentClient(spt: string, credentialHeader: string) { +function setPaymentCredential( + headers: Headers, + credentialHeader: PaymentCredentialHeader, + credential: string, +): void { + if (credentialHeader === PAYMENT_AUTHORIZATION_HEADER) { + headers.set(PAYMENT_AUTHORIZATION_HEADER, credential); + return; + } + headers.set(DEFAULT_CREDENTIAL_HEADER, credential); +} + +function createStripePaymentClient( + spt: string, + credentialHeader: PaymentCredentialHeader, +) { const stripeCharge = Method.toClient(StripeMethods.charge, { async createCredential({ challenge }) { return Credential.serialize({ @@ -99,7 +117,7 @@ function createStripePaymentClient(spt: string, credentialHeader: string) { }, setCredential(request, credential) { const nextHeaders = new Headers(request.headers); - nextHeaders.set(credentialHeader, credential); + setPaymentCredential(nextHeaders, credentialHeader, credential); return { ...request, headers: nextHeaders }; }, }), @@ -195,13 +213,13 @@ export async function payWithSpt( credentialHeader, ).createCredential(initialResponse); + const retryHeaders = new Headers(requestHeaders); + setPaymentCredential(retryHeaders, credentialHeader, credential); + const retryResponse = await fetch(url, { method: httpMethod, body: data, - headers: { - ...requestHeaders, - [credentialHeader]: credential, - }, + headers: retryHeaders, }); return readPayResult(retryResponse); From 4c3bba844aeebba54ebecee9211c98e937bce970 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Fri, 28 Aug 2026 13:01:17 -0400 Subject: [PATCH 25/26] Parse Payment scheme boundaries outside quoted WWW-Authenticate values. Quoted auth-params like description="Payment required" were treated as a new scheme start, which could drop header="Payment-Authorization". Co-authored-by: Cursor Committed-By-Agent: cursor --- .../commands/mpp/credential-header.test.ts | 43 +++++++++++++++ .../cli/src/commands/mpp/credential-header.ts | 53 +++++++++++++++++-- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/mpp/credential-header.test.ts b/packages/cli/src/commands/mpp/credential-header.test.ts index 424690a7..bf8bc76c 100644 --- a/packages/cli/src/commands/mpp/credential-header.test.ts +++ b/packages/cli/src/commands/mpp/credential-header.test.ts @@ -53,6 +53,49 @@ describe('resolvePaymentCredentialHeader', () => { ); }); + it('does not treat Payment inside a quoted auth-param as a new scheme', () => { + const wwwAuthenticate = [ + 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', + 'description="Payment required",', + 'header="Payment-Authorization",', + `request="${STRIPE_REQUEST}"`, + ].join(' '); + + expect(resolvePaymentCredentialHeader(wwwAuthenticate, 'ch_001')).toBe( + PAYMENT_AUTHORIZATION_HEADER, + ); + }); + + it('still splits real Payment schemes after a quoted Payment substring', () => { + const wwwAuthenticate = [ + 'Payment id="tempo_001", realm="merchant.example", method="tempo", intent="charge",', + 'description="Payment required", request="e30=",', + 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', + 'header="Payment-Authorization",', + `request="${STRIPE_REQUEST}"`, + ].join(' '); + + expect(resolvePaymentCredentialHeader(wwwAuthenticate, 'ch_001')).toBe( + PAYMENT_AUTHORIZATION_HEADER, + ); + expect(resolvePaymentCredentialHeader(wwwAuthenticate, 'tempo_001')).toBe( + DEFAULT_CREDENTIAL_HEADER, + ); + }); + + it('ignores escaped quotes when locating Payment scheme boundaries', () => { + const wwwAuthenticate = [ + 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', + 'description="Say \\"Payment required\\", then pay",', + 'header="Payment-Authorization",', + `request="${STRIPE_REQUEST}"`, + ].join(' '); + + expect(resolvePaymentCredentialHeader(wwwAuthenticate, 'ch_001')).toBe( + PAYMENT_AUTHORIZATION_HEADER, + ); + }); + it('rejects an unsupported advertised header', () => { const wwwAuthenticate = [ 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', diff --git a/packages/cli/src/commands/mpp/credential-header.ts b/packages/cli/src/commands/mpp/credential-header.ts index 2af1658e..e13a6e08 100644 --- a/packages/cli/src/commands/mpp/credential-header.ts +++ b/packages/cli/src/commands/mpp/credential-header.ts @@ -53,10 +53,7 @@ function equalsHeaderName(left: string, right: string): boolean { } function paymentSchemeChunks(wwwAuthenticate: string): string[] { - const starts: number[] = []; - for (const match of wwwAuthenticate.matchAll(/Payment\s+/gi)) { - if (match.index !== undefined) starts.push(match.index); - } + const starts = paymentSchemeStarts(wwwAuthenticate); return starts.map((start, index) => { const nextStart = starts[index + 1]; const end = nextStart === undefined ? wwwAuthenticate.length : nextStart; @@ -64,6 +61,54 @@ function paymentSchemeChunks(wwwAuthenticate: string): string[] { }); } +/** + * Scheme starts are `Payment` followed by whitespace, ignoring that same + * substring inside quoted auth-param values (e.g. description="Payment required"). + */ +function paymentSchemeStarts(wwwAuthenticate: string): number[] { + const starts: number[] = []; + let i = 0; + while (i < wwwAuthenticate.length) { + const char = wwwAuthenticate[i]; + if (char === '"') { + i = skipQuotedString(wwwAuthenticate, i); + continue; + } + if (isPaymentSchemeAt(wwwAuthenticate, i)) { + starts.push(i); + i += 'Payment'.length; + continue; + } + i += 1; + } + return starts; +} + +function skipQuotedString(value: string, quoteIndex: number): number { + let i = quoteIndex + 1; + while (i < value.length) { + const char = value[i]; + if (char === '\\') { + i += 2; + continue; + } + if (char === '"') { + return i + 1; + } + i += 1; + } + return value.length; +} + +function isPaymentSchemeAt(value: string, index: number): boolean { + const scheme = 'Payment'; + if (index + scheme.length >= value.length) return false; + if (value.slice(index, index + scheme.length).toLowerCase() !== 'payment') { + return false; + } + return /\s/.test(value[index + scheme.length] ?? ''); +} + function authParam(chunk: string, name: string): string | undefined { const pattern = new RegExp( `(?:^|[,\\s])${name}\\s*=\\s*(?:"((?:\\\\.|[^"\\\\])*)"|([^,\\s]+))`, From 577da368fb799a39a9ef22c85fa19c1664854edf Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Fri, 28 Aug 2026 13:45:30 -0400 Subject: [PATCH 26/26] Use mppx 0.9.1 to parse the Payment credential header. mppx now keeps challenge.header, so mpp pay no longer re-parses WWW-Authenticate scheme boundaries itself. Co-authored-by: Cursor Committed-By-Agent: cursor --- packages/cli/package.json | 2 +- .../commands/mpp/credential-header.test.ts | 107 ++---------------- .../cli/src/commands/mpp/credential-header.ts | 85 +------------- packages/cli/src/commands/mpp/decode.test.ts | 59 ++++++++++ packages/cli/src/commands/mpp/decode.ts | 7 +- packages/cli/src/commands/mpp/pay.tsx | 42 +++---- pnpm-lock.yaml | 55 +++++---- 7 files changed, 123 insertions(+), 234 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index bf8eddd9..3e79e598 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,7 +36,7 @@ "incur": "^0.4.26", "ink": "^5.2.1", "ink-spinner": "^5.0.0", - "mppx": "0.8.15", + "mppx": "0.9.1", "qrcode": "^1.5.4", "react": "^18.3.1", "strip-ansi": "^7.2.0", diff --git a/packages/cli/src/commands/mpp/credential-header.test.ts b/packages/cli/src/commands/mpp/credential-header.test.ts index bf8bc76c..35ed7579 100644 --- a/packages/cli/src/commands/mpp/credential-header.test.ts +++ b/packages/cli/src/commands/mpp/credential-header.test.ts @@ -3,119 +3,28 @@ import { DEFAULT_CREDENTIAL_HEADER, PAYMENT_AUTHORIZATION_HEADER, canonicalizeCredentialHeader, - resolvePaymentCredentialHeader, shouldEchoCredentialHeader, } from './credential-header'; -const STRIPE_REQUEST = Buffer.from( - JSON.stringify({ - amount: '1000', - currency: 'usd', - methodDetails: { networkId: 'net_001', paymentMethodTypes: ['card'] }, - }), -).toString('base64'); - -describe('resolvePaymentCredentialHeader', () => { - it('defaults to Authorization when the challenge omits header', () => { - const wwwAuthenticate = [ - 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', - `request="${STRIPE_REQUEST}"`, - ].join(' '); - - expect(resolvePaymentCredentialHeader(wwwAuthenticate, 'ch_001')).toBe( - DEFAULT_CREDENTIAL_HEADER, - ); - }); - - it('uses Payment-Authorization when the stripe challenge advertises it', () => { - const wwwAuthenticate = [ - 'Payment id="tempo_001", realm="merchant.example", method="tempo", intent="charge", request="e30=",', - 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', - 'header="Payment-Authorization",', - `request="${STRIPE_REQUEST}"`, - ].join(' '); - - expect(resolvePaymentCredentialHeader(wwwAuthenticate, 'ch_001')).toBe( - PAYMENT_AUTHORIZATION_HEADER, - ); - }); - - it('does not inherit header from a different Payment challenge', () => { - const wwwAuthenticate = [ - 'Payment id="tempo_001", realm="merchant.example", method="tempo", intent="charge",', - 'header="Payment-Authorization", request="e30=",', - 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', - `request="${STRIPE_REQUEST}"`, - ].join(' '); - - expect(resolvePaymentCredentialHeader(wwwAuthenticate, 'ch_001')).toBe( +describe('canonicalizeCredentialHeader', () => { + it('treats omitted and Authorization values as the default', () => { + expect(canonicalizeCredentialHeader(undefined)).toBe( DEFAULT_CREDENTIAL_HEADER, ); - }); - - it('does not treat Payment inside a quoted auth-param as a new scheme', () => { - const wwwAuthenticate = [ - 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', - 'description="Payment required",', - 'header="Payment-Authorization",', - `request="${STRIPE_REQUEST}"`, - ].join(' '); - - expect(resolvePaymentCredentialHeader(wwwAuthenticate, 'ch_001')).toBe( - PAYMENT_AUTHORIZATION_HEADER, - ); - }); - - it('still splits real Payment schemes after a quoted Payment substring', () => { - const wwwAuthenticate = [ - 'Payment id="tempo_001", realm="merchant.example", method="tempo", intent="charge",', - 'description="Payment required", request="e30=",', - 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', - 'header="Payment-Authorization",', - `request="${STRIPE_REQUEST}"`, - ].join(' '); - - expect(resolvePaymentCredentialHeader(wwwAuthenticate, 'ch_001')).toBe( - PAYMENT_AUTHORIZATION_HEADER, - ); - expect(resolvePaymentCredentialHeader(wwwAuthenticate, 'tempo_001')).toBe( + expect(canonicalizeCredentialHeader('authorization')).toBe( DEFAULT_CREDENTIAL_HEADER, ); }); - it('ignores escaped quotes when locating Payment scheme boundaries', () => { - const wwwAuthenticate = [ - 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', - 'description="Say \\"Payment required\\", then pay",', - 'header="Payment-Authorization",', - `request="${STRIPE_REQUEST}"`, - ].join(' '); - - expect(resolvePaymentCredentialHeader(wwwAuthenticate, 'ch_001')).toBe( + it('accepts Payment-Authorization', () => { + expect(canonicalizeCredentialHeader('Payment-Authorization')).toBe( PAYMENT_AUTHORIZATION_HEADER, ); }); it('rejects an unsupported advertised header', () => { - const wwwAuthenticate = [ - 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', - 'header="X-Payment",', - `request="${STRIPE_REQUEST}"`, - ].join(' '); - - expect(() => - resolvePaymentCredentialHeader(wwwAuthenticate, 'ch_001'), - ).toThrow(/Unsupported payment credential header/i); - }); -}); - -describe('canonicalizeCredentialHeader', () => { - it('treats omitted and Authorization values as the default', () => { - expect(canonicalizeCredentialHeader(undefined)).toBe( - DEFAULT_CREDENTIAL_HEADER, - ); - expect(canonicalizeCredentialHeader('authorization')).toBe( - DEFAULT_CREDENTIAL_HEADER, + expect(() => canonicalizeCredentialHeader('X-Payment')).toThrow( + /Unsupported payment credential header/i, ); }); diff --git a/packages/cli/src/commands/mpp/credential-header.ts b/packages/cli/src/commands/mpp/credential-header.ts index e13a6e08..99eaf95a 100644 --- a/packages/cli/src/commands/mpp/credential-header.ts +++ b/packages/cli/src/commands/mpp/credential-header.ts @@ -8,23 +8,10 @@ export type PaymentCredentialHeader = /** * HTTP field the client must use for the Payment credential. * - * mppx 0.8.x drops unknown WWW-Authenticate auth-params (including `header`) - * when parsing challenges, so this reads `header` from the raw challenge - * string. The protocol only allows Authorization (omitted / default) or + * `mppx` parses the challenge `header` auth-param; this only canonicalizes the + * advertised value. The protocol allows Authorization (omitted / default) or * Payment-Authorization. */ -export function resolvePaymentCredentialHeader( - wwwAuthenticate: string, - challengeId: string, -): PaymentCredentialHeader { - const chunk = paymentSchemeChunks(wwwAuthenticate).find( - (scheme) => authParam(scheme, 'id') === challengeId, - ); - return canonicalizeCredentialHeader( - chunk ? authParam(chunk, 'header') : undefined, - ); -} - export function canonicalizeCredentialHeader( value: string | undefined, ): PaymentCredentialHeader { @@ -51,71 +38,3 @@ export function shouldEchoCredentialHeader( function equalsHeaderName(left: string, right: string): boolean { return left.toLowerCase() === right.toLowerCase(); } - -function paymentSchemeChunks(wwwAuthenticate: string): string[] { - const starts = paymentSchemeStarts(wwwAuthenticate); - return starts.map((start, index) => { - const nextStart = starts[index + 1]; - const end = nextStart === undefined ? wwwAuthenticate.length : nextStart; - return wwwAuthenticate.slice(start, end).replace(/,\s*$/, ''); - }); -} - -/** - * Scheme starts are `Payment` followed by whitespace, ignoring that same - * substring inside quoted auth-param values (e.g. description="Payment required"). - */ -function paymentSchemeStarts(wwwAuthenticate: string): number[] { - const starts: number[] = []; - let i = 0; - while (i < wwwAuthenticate.length) { - const char = wwwAuthenticate[i]; - if (char === '"') { - i = skipQuotedString(wwwAuthenticate, i); - continue; - } - if (isPaymentSchemeAt(wwwAuthenticate, i)) { - starts.push(i); - i += 'Payment'.length; - continue; - } - i += 1; - } - return starts; -} - -function skipQuotedString(value: string, quoteIndex: number): number { - let i = quoteIndex + 1; - while (i < value.length) { - const char = value[i]; - if (char === '\\') { - i += 2; - continue; - } - if (char === '"') { - return i + 1; - } - i += 1; - } - return value.length; -} - -function isPaymentSchemeAt(value: string, index: number): boolean { - const scheme = 'Payment'; - if (index + scheme.length >= value.length) return false; - if (value.slice(index, index + scheme.length).toLowerCase() !== 'payment') { - return false; - } - return /\s/.test(value[index + scheme.length] ?? ''); -} - -function authParam(chunk: string, name: string): string | undefined { - const pattern = new RegExp( - `(?:^|[,\\s])${name}\\s*=\\s*(?:"((?:\\\\.|[^"\\\\])*)"|([^,\\s]+))`, - 'i', - ); - const match = chunk.match(pattern); - if (!match) return undefined; - if (match[1] !== undefined) return match[1].replace(/\\(.)/g, '$1'); - return match[2]; -} diff --git a/packages/cli/src/commands/mpp/decode.test.ts b/packages/cli/src/commands/mpp/decode.test.ts index 265d427b..b4e2d9b1 100644 --- a/packages/cli/src/commands/mpp/decode.test.ts +++ b/packages/cli/src/commands/mpp/decode.test.ts @@ -56,6 +56,65 @@ describe('decodeStripeChallenge', () => { }); }); + it('does not inherit header from a different Payment challenge', () => { + const header = [ + 'Payment id="tempo_001", realm="merchant.example", method="tempo", intent="charge",', + 'header="Payment-Authorization", request="e30=",', + 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', + `request="${encodeRequest({ + amount: '1000', + currency: 'usd', + methodDetails: { + networkId: 'net_001', + paymentMethodTypes: ['card'], + }, + })}"`, + ].join(' '); + + expect(decodeStripeChallenge(header)).not.toHaveProperty('header'); + }); + + it('keeps header when a quoted description contains Payment', () => { + const header = [ + 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', + 'description="Payment required",', + 'header="Payment-Authorization",', + `request="${encodeRequest({ + amount: '1000', + currency: 'usd', + methodDetails: { + networkId: 'net_001', + paymentMethodTypes: ['card'], + }, + })}"`, + ].join(' '); + + expect(decodeStripeChallenge(header)).toMatchObject({ + header: 'Payment-Authorization', + description: 'Payment required', + network_id: 'net_001', + }); + }); + + it('rejects an unsupported advertised header', () => { + const header = [ + 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', + 'header="X-Payment",', + `request="${encodeRequest({ + amount: '1000', + currency: 'usd', + methodDetails: { + networkId: 'net_001', + paymentMethodTypes: ['card'], + }, + })}"`, + ].join(' '); + + expect(() => decodeStripeChallenge(header)).toThrow( + /Unsupported payment credential header/i, + ); + }); + it('handles escaped quoted-string values in challenge parameters', () => { const header = [ 'Payment id="ch_001",', diff --git a/packages/cli/src/commands/mpp/decode.ts b/packages/cli/src/commands/mpp/decode.ts index 25d570b3..b58a0337 100644 --- a/packages/cli/src/commands/mpp/decode.ts +++ b/packages/cli/src/commands/mpp/decode.ts @@ -1,7 +1,7 @@ import { Challenge } from 'mppx'; import { sanitizeDeep } from '../../utils/sanitize-text'; import { - resolvePaymentCredentialHeader, + canonicalizeCredentialHeader, shouldEchoCredentialHeader, } from './credential-header'; @@ -124,10 +124,7 @@ export function decodeStripeChallenge( const { challenge, networkId, request } = resolveStripeChallenge( Challenge.deserializeList(challengeHeader), ); - const credentialHeader = resolvePaymentCredentialHeader( - challengeHeader, - challenge.id, - ); + const credentialHeader = canonicalizeCredentialHeader(challenge.header); return sanitizeDeep({ id: challenge.id, diff --git a/packages/cli/src/commands/mpp/pay.tsx b/packages/cli/src/commands/mpp/pay.tsx index 07c0a4a3..60288d7e 100644 --- a/packages/cli/src/commands/mpp/pay.tsx +++ b/packages/cli/src/commands/mpp/pay.tsx @@ -14,8 +14,7 @@ import { DEFAULT_CREDENTIAL_HEADER, PAYMENT_AUTHORIZATION_HEADER, type PaymentCredentialHeader, - resolvePaymentCredentialHeader, - shouldEchoCredentialHeader, + canonicalizeCredentialHeader, } from './credential-header'; import { decodeStripeChallenge, @@ -59,14 +58,6 @@ export async function readPayResult(response: Response): Promise { }); } -function withAdvertisedHeader( - challenge: T, - credentialHeader: PaymentCredentialHeader, -): T { - if (!shouldEchoCredentialHeader(credentialHeader)) return challenge; - return { ...challenge, header: credentialHeader } as T; -} - function setPaymentCredential( headers: Headers, credentialHeader: PaymentCredentialHeader, @@ -79,14 +70,12 @@ function setPaymentCredential( headers.set(DEFAULT_CREDENTIAL_HEADER, credential); } -function createStripePaymentClient( - spt: string, - credentialHeader: PaymentCredentialHeader, -) { +function createStripePaymentClient(spt: string) { const stripeCharge = Method.toClient(StripeMethods.charge, { async createCredential({ challenge }) { + canonicalizeCredentialHeader(challenge.header); return Credential.serialize({ - challenge: withAdvertisedHeader(challenge, credentialHeader), + challenge, payload: { spt }, }); }, @@ -96,8 +85,9 @@ function createStripePaymentClient( { ...StripeMethods.charge, intent: 'session' as const }, { async createCredential({ challenge }) { + canonicalizeCredentialHeader(challenge.header); return Credential.serialize({ - challenge: withAdvertisedHeader(challenge, credentialHeader), + challenge, payload: { action: 'open', grantedToken: spt }, }); }, @@ -112,10 +102,13 @@ function createStripePaymentClient( isPaymentRequired(response) { return response.status === 402; }, - getChallenge(response) { - return getStripeChargeChallengeFromResponse(response); + getChallenges(response) { + return [getStripeChargeChallengeFromResponse(response)]; }, - setCredential(request, credential) { + setCredential(request, credential, options) { + const credentialHeader = canonicalizeCredentialHeader( + options?.challenge?.header, + ); const nextHeaders = new Headers(request.headers); setPaymentCredential(nextHeaders, credentialHeader, credential); return { ...request, headers: nextHeaders }; @@ -204,14 +197,9 @@ export async function payWithSpt( } const challenge = getStripeChargeChallengeFromResponse(initialResponse); - const credentialHeader = resolvePaymentCredentialHeader( - wwwAuthenticate, - challenge.id, - ); - const credential = await createStripePaymentClient( - spt, - credentialHeader, - ).createCredential(initialResponse); + const credentialHeader = canonicalizeCredentialHeader(challenge.header); + const credential = + await createStripePaymentClient(spt).createCredential(initialResponse); const retryHeaders = new Headers(requestHeaders); setPaymentCredential(retryHeaders, credentialHeader, credential); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83fbc6ba..c1ef4014 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: ^5.0.0 version: 5.0.0(ink@5.2.1(@types/react@18.3.29)(react@18.3.1))(react@18.3.1) mppx: - specifier: 0.8.15 - version: 0.8.15(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(express@5.2.1)(hono@4.12.34)(typescript@5.9.3)(viem@2.55.10(typescript@5.9.3)(zod@4.4.3)) + specifier: 0.9.1 + version: 0.9.1(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(express@5.2.1)(hono@4.12.34)(typescript@5.9.3)(viem@2.55.10(typescript@5.9.3)(zod@4.4.3)) qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -889,8 +889,8 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@stripe/stripe-js@9.9.0': - resolution: {integrity: sha512-Vwqe6Q5cU4i82tPyAv2BpaW/fQSNdOSO4/J8EeDLPp5/oIZiMmdB+Hgh863zFH+rtoxpuWGvD1L7QPh8k1Rdvw==} + '@stripe/stripe-js@9.13.0': + resolution: {integrity: sha512-/0c72BUgzzVkVTlsw5uBn8x3waTdVJ/PZGfQ6jY1eu6K7olUPf4d9lgDPA9/0sIdsR8j7o3QIG8fOCO6ItcL7A==} engines: {node: '>=12.16'} '@toon-format/toon@2.3.0': @@ -1377,10 +1377,6 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - eventsource-parser@3.1.0: - resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} - engines: {node: '>=18.0.0'} - eventsource-parser@3.1.1: resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} @@ -1825,24 +1821,42 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - mppx@0.8.15: - resolution: {integrity: sha512-+4jQRYB3AbgATfsZZAen7SxDC4miAPhUokTmBgda5OORZKvPnbWKAKHMHK1oMKsxWTVMYBwfgxmiJYAEe6I47g==} + mppx@0.9.1: + resolution: {integrity: sha512-mmzOHcUnyvxXBFJQWWFeaT6+uwRuphqmzFZXfJjDKlmEQ559oEseQusaXs68husPKSYZU9WCwRElhi55+5I+5A==} hasBin: true peerDependencies: '@modelcontextprotocol/sdk': '>=1.25.0' + '@x402/core': '>=2.22.0' + '@x402/express': '>=2.22.0' + '@x402/hono': '>=2.22.0' + '@x402/mcp': '>=2.22.0' + '@x402/next': '>=2.22.0' elysia: '>=1' express: '>=5' hono: '>=4.12.25' + next: '>=16.2.6' viem: '>=2.54.0' peerDependenciesMeta: '@modelcontextprotocol/sdk': optional: true + '@x402/core': + optional: true + '@x402/express': + optional: true + '@x402/hono': + optional: true + '@x402/mcp': + optional: true + '@x402/next': + optional: true elysia: optional: true express: optional: true hono: optional: true + next: + optional: true mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} @@ -2247,6 +2261,10 @@ packages: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} + structured-headers@2.0.3: + resolution: {integrity: sha512-4g5yxhlDMClRwCcfKfLeS7Z8yAVdOWGDADwm80Poh1iReU2KVKLGBlqwpHWJ2qovq0+ZIf1atAEO1eua2o9Rgg==} + engines: {node: '>=18', npm: '>=6'} + stubborn-fs@2.0.0: resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} @@ -3162,7 +3180,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@stripe/stripe-js@9.9.0': {} + '@stripe/stripe-js@9.13.0': {} '@toon-format/toon@2.3.0': {} @@ -3665,10 +3683,7 @@ snapshots: eventemitter3@5.0.1: {} - eventsource-parser@3.1.0: {} - - eventsource-parser@3.1.1: - optional: true + eventsource-parser@3.1.1: {} eventsource@3.0.7: dependencies: @@ -4119,12 +4134,12 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 - mppx@0.8.15(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(express@5.2.1)(hono@4.12.34)(typescript@5.9.3)(viem@2.55.10(typescript@5.9.3)(zod@4.4.3)): + mppx@0.9.1(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(express@5.2.1)(hono@4.12.34)(typescript@5.9.3)(viem@2.55.10(typescript@5.9.3)(zod@4.4.3)): dependencies: - '@stripe/stripe-js': 9.9.0 - eventsource-parser: 3.1.0 - incur: 0.4.26 + '@stripe/stripe-js': 9.13.0 + eventsource-parser: 3.1.1 ox: 0.14.33(typescript@5.9.3)(zod@4.4.3) + structured-headers: 2.0.3 viem: 2.55.10(typescript@5.9.3)(zod@4.4.3) zod: 4.4.3 optionalDependencies: @@ -4573,6 +4588,8 @@ snapshots: strip-json-comments@2.0.1: {} + structured-headers@2.0.3: {} + stubborn-fs@2.0.0: dependencies: stubborn-utils: 1.0.2