diff --git a/.changeset/payment-authorization-header.md b/.changeset/payment-authorization-header.md new file mode 100644 index 00000000..19f881db --- /dev/null +++ b/.changeset/payment-authorization-header.md @@ -0,0 +1,5 @@ +--- +"@stripe/link-cli": patch +--- + +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/CLAUDE.md b/CLAUDE.md index 194e88b7..af2ef80c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,8 @@ node packages/cli/dist/cli.js ### SDK Resources Defined in `packages/sdk/src/resources/interfaces.ts`: +- `IAttestationsResource` — Privacy Pass Blind RSA token issuance +- `ICredentialsResource` — signed user info issuance - `ISpendRequestResource` — CRUD + request-approval for spend requests The SDK only accepts credentials. Device authorization, refresh-token @@ -50,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`, `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. @@ -108,6 +110,43 @@ 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. +### 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. + +`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. + +### 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. + +`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 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. + +### 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. + +`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 `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. +- 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. @@ -135,7 +174,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 @@ -149,3 +188,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 `identity` command group. Omitted from `--help`, `--llms`, and MCP otherwise. | diff --git a/README.md b/README.md index 0e14c91c..71d1a617 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,37 @@ 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. +### Identity + +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 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) 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: + +```bash +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 +``` + +`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 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 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 A spend request moves through: **create** → **request approval** → **approved** (with credentials). 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/__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/cli.tsx b/packages/cli/src/cli.tsx index d33a2fd0..f6e461d5 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 { createBalancesCli } from './commands/balances'; 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'; @@ -88,6 +89,23 @@ if (!isAgent && process.stdout.isTTY) { } } +const identityCommandsEnabled = + process.env.LINK_IDENTITY_COMMANDS === '1' || + process.env.LINK_IDENTITY_COMMANDS === 'true'; + +if (identityCommandsEnabled) { + cli.command( + createIdentityCli({ + createAttestationsResource: (accessToken) => + factory.createAttestationsResource(accessToken), + createCredentialsResource: (accessToken) => + factory.createCredentialsResource(accessToken), + createWebBotAuthResource: () => factory.createWebBotAuthResource(), + authStorage, + envAccessToken, + }), + ); +} 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..2cff18f4 --- /dev/null +++ b/packages/cli/src/commands/attestations/index.tsx @@ -0,0 +1,39 @@ +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: + 'A privacy-preserving token that shows Link attests to your agent.', + }); + + cli.command('request', { + description: + 'Get privacy-preserving tokens that show Link attests to your agent.', + options: requestOptions, + mcp: false, + outputPolicy: 'agent-only' as const, + async run(c) { + const { count, issuer, accessToken, poolFile } = c.options; + const { remainingCount, saveIssuedTokens } = await import('./pool'); + + const result = await createResource(accessToken).request({ + issuer, + count, + }); + saveIssuedTokens(poolFile, result); + return { + ...result, + pool: { + path: poolFile, + remaining: remainingCount(poolFile), + }, + }; + }, + }); + + return cli; +} 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 new file mode 100644 index 00000000..15d7fcb6 --- /dev/null +++ b/packages/cli/src/commands/attestations/schema.ts @@ -0,0 +1,27 @@ +import { z } from 'incur'; +import { DEFAULT_AAT_POOL_PATH } from './pool'; + +export const requestOptions = z.object({ + count: z.coerce + .number() + .int() + .positive() + .max(100) + .describe('Number of tokens to request'), + issuer: z + .string() + .default('https://api.link.com') + .describe('Link origin that attests to your agent'), + accessToken: z + .string() + .optional() + .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/credentials/holder-key.ts b/packages/cli/src/commands/credentials/holder-key.ts new file mode 100644 index 00000000..0e09fa5b --- /dev/null +++ b/packages/cli/src/commands/credentials/holder-key.ts @@ -0,0 +1,109 @@ +import { + type KeyObject, + createPrivateKey, + generateKeyPairSync, +} from 'node:crypto'; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +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). + */ +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..b040495b --- /dev/null +++ b/packages/cli/src/commands/credentials/index.tsx @@ -0,0 +1,32 @@ +import type { ICredentialsResource } from '@stripe/link-sdk'; +import { Cli } from 'incur'; +import { getOptions } from './schema'; + +export function createCredentialsCli( + createResource: (accessToken?: string) => ICredentialsResource, +) { + const cli = Cli.create('credentials', { + description: + 'User info that has been signed, proving it comes from Link.', + }); + + cli.command('get', { + description: + '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) { + const { keyFile, keyType, accessToken } = c.options; + + const { issueCredential } = await import('./issue'); + return issueCredential({ + resource: createResource(accessToken), + 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..6629e677 --- /dev/null +++ b/packages/cli/src/commands/credentials/schema.ts @@ -0,0 +1,23 @@ +import { z } from 'incur'; +import { DEFAULT_HOLDER_KEY_PATH } from './holder-key'; + +export const getOptions = z.object({ + keyFile: z + .string() + .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.', + ), + keyType: z + .enum(['ed25519', 'p256']) + .default('ed25519') + .describe( + 'Key type to generate when --key-file does not exist yet. Ignored when the file already exists.', + ), + accessToken: z + .string() + .optional() + .describe( + '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..acfae696 --- /dev/null +++ b/packages/cli/src/commands/identity/index.tsx @@ -0,0 +1,34 @@ +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: (accessToken?: string) => IAttestationsResource; + createCredentialsResource: (accessToken?: string) => ICredentialsResource; + createWebBotAuthResource: () => IWebBotAuthResource; + authStorage?: CliAuthStorage; + envAccessToken?: string; +}) { + const cli = Cli.create('identity', { + description: 'Prove your agent and user identity with Link.', + }); + + 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/mpp/credential-header.test.ts b/packages/cli/src/commands/mpp/credential-header.test.ts new file mode 100644 index 00000000..35ed7579 --- /dev/null +++ b/packages/cli/src/commands/mpp/credential-header.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_CREDENTIAL_HEADER, + PAYMENT_AUTHORIZATION_HEADER, + canonicalizeCredentialHeader, + shouldEchoCredentialHeader, +} from './credential-header'; + +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('accepts Payment-Authorization', () => { + expect(canonicalizeCredentialHeader('Payment-Authorization')).toBe( + PAYMENT_AUTHORIZATION_HEADER, + ); + }); + + it('rejects an unsupported advertised header', () => { + expect(() => canonicalizeCredentialHeader('X-Payment')).toThrow( + /Unsupported payment credential header/i, + ); + }); + + 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..99eaf95a --- /dev/null +++ b/packages/cli/src/commands/mpp/credential-header.ts @@ -0,0 +1,40 @@ +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` parses the challenge `header` auth-param; this only canonicalizes the + * advertised value. The protocol allows Authorization (omitted / default) or + * Payment-Authorization. + */ +export function canonicalizeCredentialHeader( + value: string | undefined, +): PaymentCredentialHeader { + 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: PaymentCredentialHeader, +): boolean { + return header === PAYMENT_AUTHORIZATION_HEADER; +} + +function equalsHeaderName(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase(); +} diff --git a/packages/cli/src/commands/mpp/decode.test.ts b/packages/cli/src/commands/mpp/decode.test.ts index ca5a102d..b4e2d9b1 100644 --- a/packages/cli/src/commands/mpp/decode.test.ts +++ b/packages/cli/src/commands/mpp/decode.test.ts @@ -35,6 +35,86 @@ 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('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 55d4c21d..b58a0337 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 { + canonicalizeCredentialHeader, + 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 Payment-Authorization. */ + header?: string; network_id: string; request_json: Record; } @@ -118,6 +124,7 @@ export function decodeStripeChallenge( const { challenge, networkId, request } = resolveStripeChallenge( Challenge.deserializeList(challengeHeader), ); + const credentialHeader = canonicalizeCredentialHeader(challenge.header); return sanitizeDeep({ id: challenge.id, @@ -127,6 +134,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..60288d7e 100644 --- a/packages/cli/src/commands/mpp/pay.tsx +++ b/packages/cli/src/commands/mpp/pay.tsx @@ -10,6 +10,12 @@ 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 { + DEFAULT_CREDENTIAL_HEADER, + PAYMENT_AUTHORIZATION_HEADER, + type PaymentCredentialHeader, + canonicalizeCredentialHeader, +} from './credential-header'; import { decodeStripeChallenge, getStripeChargeChallengeFromResponse, @@ -52,9 +58,22 @@ export async function readPayResult(response: Response): Promise { }); } +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) { const stripeCharge = Method.toClient(StripeMethods.charge, { async createCredential({ challenge }) { + canonicalizeCredentialHeader(challenge.header); return Credential.serialize({ challenge, payload: { spt }, @@ -66,6 +85,7 @@ function createStripePaymentClient(spt: string) { { ...StripeMethods.charge, intent: 'session' as const }, { async createCredential({ challenge }) { + canonicalizeCredentialHeader(challenge.header); return Credential.serialize({ challenge, payload: { action: 'open', grantedToken: spt }, @@ -82,12 +102,15 @@ function createStripePaymentClient(spt: string) { 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); - nextHeaders.set('Authorization', credential); + setPaymentCredential(nextHeaders, credentialHeader, credential); return { ...request, headers: nextHeaders }; }, }), @@ -168,16 +191,23 @@ export async function payWithSpt( return readPayResult(initialResponse); } - const authHeader = + 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 = canonicalizeCredentialHeader(challenge.header); + const credential = await createStripePaymentClient(spt).createCredential(initialResponse); + const retryHeaders = new Headers(requestHeaders); + setPaymentCredential(retryHeaders, credentialHeader, credential); + const retryResponse = await fetch(url, { method: httpMethod, body: data, - headers: { - ...requestHeaders, - Authorization: authHeader, - }, + headers: retryHeaders, }); return readPayResult(retryResponse); diff --git a/packages/cli/src/commands/request/index.tsx b/packages/cli/src/commands/request/index.tsx new file mode 100644 index 00000000..f2f968fb --- /dev/null +++ b/packages/cli/src/commands/request/index.tsx @@ -0,0 +1,54 @@ +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'; + +export function createRequestCli( + createCredentialsResource: () => ICredentialsResource, + createWebBotAuthResource: () => IWebBotAuthResource, + authStorage?: CliAuthStorage, + envAccessToken?: string, +) { + return Cli.create('request', { + description: + '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' }, + 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) { + // Root commands don't take middleware, so guard inline. + requireAuthGuard(c, authStorage, envAccessToken); + + const { url } = c.args; + const { claims, method, data, header, keyFile, keyType, poolFile } = + c.options; + + const { runIdentityRequest } = await import('./run'); + const result = await runIdentityRequest({ + url, + claims, + method, + data, + header, + keyFile, + keyType, + poolFile, + createCredentialsResource, + createWebBotAuthResource, + sanitizeDeep, + }); + if (!result.ok) { + return c.error(result.error); + } + return result.value; + }, + }); +} 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..2c5951aa --- /dev/null +++ b/packages/cli/src/commands/request/present.test.ts @@ -0,0 +1,168 @@ +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 claims-required 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('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']); + 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..36b2d238 --- /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 for identity 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 identity 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/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..61a6eea5 --- /dev/null +++ b/packages/cli/src/commands/request/run.test.ts @@ -0,0 +1,211 @@ +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 { remainingCount, 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:', + ); + }); + + 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 new file mode 100644 index 00000000..683370f2 --- /dev/null +++ b/packages/cli/src/commands/request/run.ts @@ -0,0 +1,461 @@ +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; + }; + prepared_headers?: { + attestation?: string; + identity_presentation?: string; + }; + response: unknown; +}; + +function parseBody( + body: string, + sanitizeDeep: (value: unknown) => unknown, +): unknown { + try { + return sanitizeDeep(JSON.parse(body)); + } catch { + return sanitizeDeep(body); + } +} + +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; + initialResponse?: { response: Response; body: string }; + fetchImpl?: typeof fetch; + sanitizeDeep: (value: unknown) => unknown; +}): Promise< + | { ok: true; value: IdentityRequestResult } + | { ok: false; error: IdentityRequestError } +> { + const { + url, + claims, + method, + data, + header, + prepare = false, + 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 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; + 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 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, + }; + } + } + + 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 (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)); + } + 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 new file mode 100644 index 00000000..e4b8db79 --- /dev/null +++ b/packages/cli/src/commands/request/schema.ts @@ -0,0 +1,50 @@ +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 + .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 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() + .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(DEFAULT_HOLDER_KEY_PATH) + .describe( + '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( + 'Key type to generate when --key-file does not exist yet. Ignored when it does.', + ), +}); diff --git a/packages/cli/src/utils/__tests__/resource-factory.test.ts b/packages/cli/src/utils/__tests__/resource-factory.test.ts index 5649a90e..4d56a67c 100644 --- a/packages/cli/src/utils/__tests__/resource-factory.test.ts +++ b/packages/cli/src/utils/__tests__/resource-factory.test.ts @@ -25,6 +25,12 @@ describe('ResourceFactory', () => { const factory = new ResourceFactory(); expect(factory.createAuthResource()).toBe(factory.createAuthResource()); + expect(factory.createAttestationsResource()).toBe( + factory.createAttestationsResource(), + ); + expect(factory.createCredentialsResource()).toBe( + factory.createCredentialsResource(), + ); expect(factory.createSpendRequestResource()).toBe( factory.createSpendRequestResource(), ); @@ -38,6 +44,8 @@ describe('ResourceFactory', () => { factory.createWebBotAuthResource(), ); 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 86b23349..c2aa8ce9 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -1,6 +1,8 @@ import { type AccessTokenProvider, + type IAttestationsResource, type IBalancesResource, + type ICredentialsResource, type IPaymentMethodsResource, type IReportResource, type IShippingAddressResource, @@ -70,6 +72,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 +114,8 @@ export class ResourceFactory { private _authResource?: IAuthResource; private accessTokenProvider?: ReturnType; private sdkClient?: Link; + private attestationsResource?: IAttestationsResource; + private credentialsResource?: ICredentialsResource; private spendRequestResource?: ISpendRequestResource; private paymentMethodsResource?: IPaymentMethodsResource; private shippingAddressResource?: IShippingAddressResource; @@ -134,11 +142,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 +222,46 @@ 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; + } + + 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 32c16e5f..5c808531 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -1,7 +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, @@ -21,6 +25,8 @@ import { UserInfoResource } from '@/resources/user-info'; 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; @@ -32,6 +38,8 @@ export class Link { readonly reports: IReportResource; 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 a987ade7..79d96cd9 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -9,4 +9,11 @@ export { } from './errors'; 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 new file mode 100644 index 00000000..1044d014 --- /dev/null +++ b/packages/sdk/src/resources/__tests__/attestations-crypto.test.ts @@ -0,0 +1,125 @@ +import { createHash, generateKeyPairSync, randomBytes } from 'node:crypto'; +import { + computeChallengeDigest, + encodeStableTokenChallenge, + generateBlindedMessages, + parseFinalToken, + 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('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, + 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..6b5759f0 --- /dev/null +++ b/packages/sdk/src/resources/__tests__/attestations.test.ts @@ -0,0 +1,180 @@ +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({ + 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/__tests__/credentials.test.ts b/packages/sdk/src/resources/__tests__/credentials.test.ts new file mode 100644 index 00000000..58ca0132 --- /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/credential', + }); + } + expect(url).toBe('https://issuer.example/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 8ecd8b96..b9dd2546 100644 --- a/packages/sdk/src/resources/__tests__/factory.test.ts +++ b/packages/sdk/src/resources/__tests__/factory.test.ts @@ -1,4 +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'; @@ -14,6 +16,8 @@ describe('Link', () => { apiBaseUrl: 'https://api.example.com', }); + 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); @@ -22,6 +26,8 @@ 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.credentials.issue).toBeTypeOf('function'); expect(client.paymentMethods.list).toBeTypeOf('function'); expect(client.transactions.list).toBeTypeOf('function'); }); 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/attestations-crypto.ts b/packages/sdk/src/resources/attestations-crypto.ts new file mode 100644 index 00000000..5c740b12 --- /dev/null +++ b/packages/sdk/src/resources/attestations-crypto.ts @@ -0,0 +1,436 @@ +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; +} + +/** + * 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 challenge = Buffer.alloc(2 + 2 + issuerBytes.length + 1 + 2); + 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(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; +} + +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, + }; +} diff --git a/packages/sdk/src/resources/attestations.ts b/packages/sdk/src/resources/attestations.ts new file mode 100644 index 00000000..d0e7c69e --- /dev/null +++ b/packages/sdk/src/resources/attestations.ts @@ -0,0 +1,388 @@ +import { createHash } from 'node:crypto'; +import type { LinkOptions } from '@/config'; +import { + LinkApiError, + LinkConfigurationError, + LinkResponseError, + LinkTransportError, +} from '@/errors'; +import { + parseIssuerOrigin, + requireIssuerEndpoint, +} from '@/resources/issuer-origin'; +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-generic-batch-request'; +const CONTENT_TYPE_TOKEN_RESPONSE = + 'application/private-token-generic-batch-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 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, +): 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); + return Buffer.concat([encodeQuicVarint(vector.length), vector]); +} + +function decodeBatchTokenResponse( + body: Buffer, + elementSize: number, + expectedCount: number, +): string[] { + const prefix = readQuicVarint(body); + const vector = body.subarray(prefix.length); + if (vector.length !== prefix.value) { + throw new Error( + `BatchTokenResponse length prefix says ${prefix.value} bytes but ${vector.length} bytes follow`, + ); + } + if (vector.length === 0) throw new Error('BatchTokenResponse vector is empty'); + + 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; + } + if (signatures.length !== expectedCount) { + throw new Error( + `Issuer returned ${signatures.length} token responses for ${expectedCount} token requests`, + ); + } + return signatures; +} + +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/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/credentials.ts b/packages/sdk/src/resources/credentials.ts new file mode 100644 index 00000000..8e936f05 --- /dev/null +++ b/packages/sdk/src/resources/credentials.ts @@ -0,0 +1,152 @@ +import type { LinkOptions } from '@/config'; +import { LinkApiError, LinkResponseError, LinkTransportError } from '@/errors'; +import { + parseIssuerOrigin, + requireIssuerEndpoint, +} from '@/resources/issuer-origin'; +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 +{ + constructor(options: LinkOptions) { + super(options, ''); + } + + private async discoverCredentialEndpoint(): Promise { + 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' }); + } 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 !== issuerUrl.origin) { + throw new TypeError( + 'issuer metadata identifier must match the discovery origin', + ); + } + return requireIssuerEndpoint( + metadata.credential_endpoint, + 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 e9382d88..31175c8a 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -23,6 +23,40 @@ 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 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; @@ -77,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/issuer-origin.ts b/packages/sdk/src/resources/issuer-origin.ts new file mode 100644 index 00000000..ade08137 --- /dev/null +++ b/packages/sdk/src/resources/issuer-origin.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/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; + } } 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/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 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.