diff --git a/CLAUDE.md b/CLAUDE.md index 48d160c8..33eb3704 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,7 @@ node packages/cli/dist/cli.js Defined in `packages/sdk/src/resources/interfaces.ts`: - `IAttestationsResource` — Privacy Pass Blind RSA token issuance +- `IIdentityCredentialsResource` — signed user info issuance - `ISpendRequestResource` — CRUD + request-approval for spend requests The SDK only accepts credentials. Device authorization, refresh-token @@ -57,7 +58,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`, `user-info retrieve`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `identity attestations request`, `report`, `serve`. +Commands: `auth login|logout|status`, `user-info retrieve`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `identity attestations request`, `identity credentials request`, `report`, `serve`. The CLI also runs as an MCP server (`--mcp`) and serves skill files via `skills` subcommand, both provided by incur. @@ -144,6 +145,17 @@ Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENT - `--step` is where the agent was when the outcome occurred (max 500). `--attempt-trace` is the whole path it took, one numbered line per step, intended to be replayable by another agent. Both are optional and independent. - `--attempt-trace` intentionally carries **no** zod `.max()`. The API truncates at `REPORT_ATTEMPT_TRACE_MAX_LENGTH` (8000, exported from the SDK) and still records the report, so client-side rejection would trade a long narrative for a lost outcome. `--step` and `--freeform-context` keep their `.max(500)` because the API rejects those outright. +### 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 request` — requests signed user info proving it comes from Link (a wallet of claims such as name, email, and phone). Human TTY runs save the credential and show its path; structured output returns the credential and holder-key path. The SDK discovers and calls `credential_endpoint`; the CLI owns default holder-key persistence, claim decoding, and command registration under `packages/cli/src/commands/identity/`. + +- Discovery uses `GET https://api.link.com/.well-known/aap-issuer`. The metadata issuer must be exactly `https://api.link.com`, and `credential_endpoint` must remain on that HTTPS origin. `LINK_API_BASE_URL` does not change the credential issuer. +- `POST ` sends `{"cnf":{"jwk":}}`. +- Issuance uses the Ed25519 holder key at `~/.link/holder-key.jwk` (mode 0600). +- The issued `cnf.jwk` is checked against the requested public key before returning the credential artifact. + ### 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. @@ -170,6 +182,7 @@ Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENT Server-returned strings can contain ANSI escape sequences or control characters that spoof the terminal approval UI. Sanitization is handled automatically via `sanitizeDeep()` from `packages/cli/src/utils/sanitize-text.ts`: - **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. +- **Encoded server data decoded by the CLI** — must be sanitized after decoding. Credential issuance sanitizes claims recovered from SD-JWT disclosures in `commands/credentials/issue.ts`; sanitizing the compact credential string at the resource boundary does not sanitize its decoded values. - **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. diff --git a/README.md b/README.md index 19a91d50..78800bdd 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ All commands accept `--auth ` to store auth credentials in a specific file 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: +**Privacy-preserving tokens** that show Link attests to your agent: ```bash LINK_IDENTITY_COMMANDS=1 link-cli identity attestations request --count 10 @@ -258,6 +258,14 @@ LINK_IDENTITY_COMMANDS=1 link-cli identity attestations request --count 10 Attestation tokens can be used to respond to attestation challenges presented by downstream services. Token artifacts are written to `~/.link-cli/attestations`. +**User info that has been signed, proving it comes from Link**: + +```bash +LINK_IDENTITY_COMMANDS=1 link-cli identity credentials request +``` + +`identity credentials request` returns a signed credential bound to the CLI-managed holder key at `~/.link/holder-key.jwk`. + ### Spend request lifecycle A spend request moves through: **create** → **request approval** → **approved** (with credentials). diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 34432762..e70e9595 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -101,6 +101,8 @@ if (identityCommandsEnabled) { cli.command( createIdentityCli({ createAttestationsResource: () => factory.createAttestationsResource(), + createIdentityCredentialsResource: () => + factory.createIdentityCredentialsResource(), }), ); } diff --git a/packages/cli/src/commands/attestations/index.tsx b/packages/cli/src/commands/attestations/index.tsx index c603cfd5..92d6889a 100644 --- a/packages/cli/src/commands/attestations/index.tsx +++ b/packages/cli/src/commands/attestations/index.tsx @@ -1,5 +1,7 @@ import type { IAttestationsResource } from '@stripe/link-sdk'; import { Cli } from 'incur'; +import { renderInteractive } from '../../utils/render-interactive'; +import { SavedArtifact } from '../identity/saved-artifact'; import { exportAttestationTokens } from './export'; import { requestOptions } from './schema'; import { writeAttestationArtifact } from './storage'; @@ -27,12 +29,29 @@ export function createAttestationsCli( }), ); const outputFile = await writeAttestationArtifact(artifact); - return { + const result = { issuer: artifact.issuer, token_key_id: artifact.token_key_id, count: artifact.count, output_file: outputFile, }; + + if ( + !c.agent && + !c.formatExplicit && + !process.argv.includes('--full-output') + ) { + return renderInteractive( + , + () => result, + ); + } + + return result; }, }); diff --git a/packages/cli/src/commands/credentials/__tests__/issue.test.ts b/packages/cli/src/commands/credentials/__tests__/issue.test.ts new file mode 100644 index 00000000..f378eec1 --- /dev/null +++ b/packages/cli/src/commands/credentials/__tests__/issue.test.ts @@ -0,0 +1,139 @@ +import { generateKeyPairSync } from 'node:crypto'; +import { existsSync, mkdtempSync, statSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { + HolderPublicJwk, + IIdentityCredentialsResource, +} from '@stripe/link-sdk'; +import { holderJwkThumbprint } from '@stripe/link-sdk'; +import { describe, expect, it, vi } from 'vitest'; +import { loadHolderKey, loadOrCreateHolderKey } from '../holder-key'; +import { issueIdentityCredential } from '../issue'; + +function publicJwkFromPrivate(): HolderPublicJwk { + const privateKey = generateKeyPairSync('ed25519').privateKey; + const jwk = privateKey.export({ format: 'jwk' }) as Record; + return { kty: 'OKP', crv: 'Ed25519', x: jwk.x }; +} + +function encodeSegment(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +function compactCredential( + jwk: HolderPublicJwk, + claims: Record = { email: 'user@example.com' }, +): string { + const jwt = `${encodeSegment({ alg: 'EdDSA', typ: 'vc+sd-jwt' })}.${encodeSegment( + { + iss: 'https://api.link.com', + cnf: { jwk }, + }, + )}.sig`; + const disclosures = Object.entries(claims).map(([name, value]) => + encodeSegment(['salt', name, value]), + ); + return `${[jwt, ...disclosures].join('~')}~`; +} + +function tempDir(): string { + return mkdtempSync(join(tmpdir(), 'link-credential-')); +} + +describe('issueIdentityCredential', () => { + it('issues a managed credential and records the local key path', async () => { + const dir = tempDir(); + const keyFile = join(dir, 'holder-key.jwk'); + const resource: IIdentityCredentialsResource = { + issue: vi.fn(async ({ cnf }) => ({ + credential: compactCredential(cnf.jwk), + issuer: 'https://api.link.com', + expires_at: '2026-09-15T00:00:00Z', + })), + }; + + const result = await issueIdentityCredential({ + resource, + keyFile, + }); + + expect(existsSync(keyFile)).toBe(true); + expect(result.version).toBe(1); + expect(result.holder.path).toBe(keyFile); + expect(result.holder.created).toBe(true); + expect(result.holder.thumbprint).toBe( + holderJwkThumbprint(result.holder.jwk), + ); + expect(result.claims).toEqual({ email: 'user@example.com' }); + expect(resource.issue).toHaveBeenCalledWith({ + cnf: { jwk: result.holder.jwk }, + }); + }); + + it('sanitizes disclosed claims before returning them to the CLI', async () => { + const keyFile = join(tempDir(), 'holder-key.jwk'); + const resource: IIdentityCredentialsResource = { + issue: vi.fn(async ({ cnf }) => ({ + credential: compactCredential(cnf.jwk, { + email: '\u001b[2Juser@example.com\u0007', + }), + issuer: 'https://api.link.com', + expires_at: '2026-09-15T00:00:00Z', + })), + }; + + const result = await issueIdentityCredential({ + resource, + keyFile, + }); + + expect(result.claims).toEqual({ email: 'user@example.com' }); + }); + + it('rejects an issued credential whose cnf.jwk does not match', async () => { + const keyFile = join(tempDir(), 'holder-key.jwk'); + const other = publicJwkFromPrivate(); + const resource: IIdentityCredentialsResource = { + issue: vi.fn(async () => ({ + credential: compactCredential(other), + issuer: 'https://api.link.com', + expires_at: '2026-09-15T00:00:00Z', + })), + }; + + await expect( + issueIdentityCredential({ + resource, + keyFile, + }), + ).rejects.toThrow('does not match the requested holder public key'); + }); +}); + +describe('loadHolderKey', () => { + it('does not generate a replacement key when the file is missing', () => { + const missing = join(tempDir(), 'missing.jwk'); + expect(() => loadHolderKey(missing)).toThrow('Holder key not found'); + expect(existsSync(missing)).toBe(false); + }); + + it('loads an existing managed key', () => { + const keyFile = join(tempDir(), 'holder-key.jwk'); + const created = loadOrCreateHolderKey(keyFile); + const loaded = loadHolderKey(keyFile); + expect(statSync(keyFile).mode & 0o777).toBe(0o600); + expect(loaded.created).toBe(false); + expect(loaded.publicJwk).toEqual(created.publicJwk); + }); + + it('refuses to read or write a holder key through a symbolic link', () => { + const dir = tempDir(); + const target = join(dir, 'target.jwk'); + const keyFile = join(dir, 'holder-key.jwk'); + symlinkSync(target, keyFile); + + expect(() => loadOrCreateHolderKey(keyFile)).toThrow('symbolic link'); + expect(existsSync(target)).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/credentials/__tests__/output.test.ts b/packages/cli/src/commands/credentials/__tests__/output.test.ts new file mode 100644 index 00000000..3481680c --- /dev/null +++ b/packages/cli/src/commands/credentials/__tests__/output.test.ts @@ -0,0 +1,197 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { ReactElement } from 'react'; +import { afterAll, afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { createAttestationsCli } from '../../attestations'; +import { + SavedArtifact, + type SavedArtifactDetail, +} from '../../identity/saved-artifact'; +import { createIdentityCredentialsCli } from '../index'; + +const state = vi.hoisted(() => ({ + directory: '', + interactiveElements: [] as unknown[], +})); +vi.mock('../../../utils/render-interactive', () => ({ + renderInteractive: async ( + element: unknown, + getResult?: () => unknown | Promise, + ) => { + state.interactiveElements.push(element); + return getResult?.(); + }, +})); +vi.mock('../holder-key', async (importOriginal) => { + const actual = await importOriginal(); + const { mkdtempSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + state.directory = mkdtempSync(join(tmpdir(), 'link-credential-output-')); + return { + ...actual, + DEFAULT_HOLDER_KEY_PATH: join(state.directory, 'holder-key.jwk'), + }; +}); + +const originalTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + +beforeEach(() => { + state.interactiveElements.length = 0; + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: true, + }); + vi.spyOn(os, 'homedir').mockReturnValue(state.directory); +}); + +afterEach(() => { + if (originalTTY) { + Object.defineProperty(process.stdout, 'isTTY', originalTTY); + } else { + Reflect.deleteProperty(process.stdout, 'isTTY'); + } + vi.restoreAllMocks(); +}); + +afterAll(() => fs.rm(state.directory, { recursive: true, force: true })); + +function credentialCli() { + return createIdentityCredentialsCli(() => ({ + issue: async ({ cnf }) => ({ + credential: `header.${Buffer.from(JSON.stringify({ cnf })).toString( + 'base64url', + )}.sig~`, + issuer: 'https://api.link.com', + expires_at: '2026-09-18T00:00:00Z', + }), + })); +} + +async function run( + cli: { serve: ReturnType['serve'] }, + args: string[], +) { + let output = ''; + let exitCode: number | undefined; + const originalArgv = process.argv; + process.argv = ['node', 'link-cli', ...args]; + try { + await cli.serve(args, { + stdout: (text) => { + output += text; + }, + exit: (code) => { + exitCode = code; + }, + }); + } finally { + process.argv = originalArgv; + } + expect(exitCode ?? 0, output).toBe(0); + return output; +} + +it('prints a non-secret TTY confirmation and saves the credential', async () => { + const output = await run(credentialCli(), ['request']); + + expect(output).toBe(''); + expect(state.interactiveElements).toHaveLength(1); + const view = state.interactiveElements[0] as ReactElement<{ + message: string; + outputFile: string; + details: SavedArtifactDetail[]; + }>; + expect(view.type).toBe(SavedArtifact); + expect(view.props.message).toBe('Identity credential saved'); + expect(view.props.outputFile).toContain('.link-cli/credentials/current.json'); + expect(view.props.details).toEqual([ + { label: 'Expires', value: '2026-09-18T00:00:00Z' }, + ]); + + const directory = path.join(state.directory, '.link-cli', 'credentials'); + const files = await fs.readdir(directory); + expect(files).toHaveLength(1); + const file = path.join(directory, files[0]); + expect(JSON.parse(await fs.readFile(file, 'utf8')).credential).toContain( + '.sig~', + ); + expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + expect((await fs.stat(directory)).mode & 0o777).toBe(0o700); + expect( + Object.keys( + JSON.parse( + await fs.readFile(path.join(state.directory, 'holder-key.jwk'), 'utf8'), + ), + ), + ).toEqual(['private_jwk']); +}); + +it('returns the credential with an explicit format or non-TTY output', async () => { + expect(await run(credentialCli(), ['request', '--format', 'json'])).toContain( + '"credential"', + ); + + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: false, + }); + expect(await run(credentialCli(), ['request'])).toContain('.sig~'); +}); + +it('preserves the credential in a requested full-output envelope', async () => { + const output = JSON.parse( + await run(credentialCli(), [ + 'request', + '--full-output', + '--format', + 'json', + ]), + ); + + expect(output.ok).toBe(true); + expect(output.data.credential).toContain('.sig~'); + expect(output.data.holder.path).toBe( + path.join(state.directory, 'holder-key.jwk'), + ); + expect(await run(credentialCli(), ['request', '--full-output'])).toContain( + 'credential:', + ); +}); + +it('prints the saved attestation path without exposing raw tokens', async () => { + const home = path.join(state.directory, 'attestation-home'); + vi.spyOn(os, 'homedir').mockReturnValue(home); + const cli = createAttestationsCli(() => ({ + request: async () => ({ + tokens: ['dGVzdA'], + issuer: 'https://api.link.com', + token_key_id: 'test-key', + count: 1, + }), + })); + + const output = await run(cli, ['request', '--count', '1']); + + expect(output).toBe(''); + expect(state.interactiveElements).toHaveLength(1); + const view = state.interactiveElements[0] as ReactElement<{ + message: string; + outputFile: string; + details: SavedArtifactDetail[]; + }>; + expect(view.type).toBe(SavedArtifact); + expect(view.props.message).toBe('Attestation token saved'); + expect(view.props.outputFile).toContain( + '.link-cli/attestations/attestations-', + ); + expect(view.props.details).toEqual([{ label: 'Count', value: 1 }]); + const directory = path.join(home, '.link-cli', 'attestations'); + const files = await fs.readdir(directory); + expect(files).toHaveLength(1); + expect( + JSON.parse(await fs.readFile(path.join(directory, files[0]), 'utf8')) + .tokens, + ).toHaveLength(1); +}); diff --git a/packages/cli/src/commands/credentials/__tests__/storage.test.ts b/packages/cli/src/commands/credentials/__tests__/storage.test.ts new file mode 100644 index 00000000..d8a27bdc --- /dev/null +++ b/packages/cli/src/commands/credentials/__tests__/storage.test.ts @@ -0,0 +1,61 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IdentityCredentialIssueResult } from '../issue'; +import { writeIdentityCredentialArtifact } from '../storage'; + +const artifact: IdentityCredentialIssueResult = { + version: 1, + credential: 'credential', + issuer: 'https://api.link.com', + expires_at: '2026-09-18T00:00:00Z', + holder: { + jwk: { kty: 'OKP', crv: 'Ed25519', x: 'public-key' }, + thumbprint: 'thumbprint', + path: '/path/to/holder-key.jwk', + created: true, + }, +}; + +describe('identity credential artifact storage', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'link-credentials-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('atomically replaces the current artifact in a private directory', async () => { + const directory = path.join(tmpDir, '.link-cli', 'credentials'); + const first = await writeIdentityCredentialArtifact(artifact); + const replacement = { ...artifact, credential: 'replacement' }; + const second = await writeIdentityCredentialArtifact(replacement); + + expect(first).toBe(second); + expect(path.basename(first)).toBe('current.json'); + expect(path.dirname(first)).toBe(directory); + expect(JSON.parse(await fs.readFile(first, 'utf8'))).toEqual(replacement); + expect(await fs.readdir(directory)).toEqual(['current.json']); + expect((await fs.stat(directory)).mode & 0o777).toBe(0o700); + expect((await fs.stat(first)).mode & 0o777).toBe(0o600); + }); + + it('rejects a symbolic-link output directory', async () => { + const target = path.join(tmpDir, 'target'); + const directory = path.join(tmpDir, '.link-cli', 'credentials'); + await fs.mkdir(target); + await fs.mkdir(path.dirname(directory)); + await fs.symlink(target, directory); + + await expect(writeIdentityCredentialArtifact(artifact)).rejects.toThrow( + 'CREDENTIAL_OUTPUT_DIRECTORY_INVALID', + ); + expect(await fs.readdir(target)).toEqual([]); + }); +}); 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..c29937fd --- /dev/null +++ b/packages/cli/src/commands/credentials/holder-key.ts @@ -0,0 +1,158 @@ +import { + createPrivateKey, + generateKeyPairSync, + type KeyObject, +} from 'node:crypto'; +import { + closeSync, + constants, + fchmodSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { type HolderPublicJwk, parseHolderPublicJwk } from '@stripe/link-sdk'; + +export const DEFAULT_HOLDER_KEY_PATH = join( + homedir(), + '.link', + 'holder-key.jwk', +); + +export interface HolderKey { + privateKey: KeyObject; + publicJwk: HolderPublicJwk; + /** True when the key was generated by this call rather than read from disk. */ + created: boolean; +} + +interface StoredHolderKey { + private_jwk: Record; +} + +function readHolderKeyFile(path: string): string { + if (lstatSync(path).isSymbolicLink()) { + throw new Error('holder key path is a symbolic link'); + } + + const noFollowFlag = constants.O_NOFOLLOW ?? 0; + const descriptor = openSync(path, constants.O_RDONLY | noFollowFlag); + try { + return readFileSync(descriptor, 'utf8'); + } finally { + closeSync(descriptor); + } +} + +function toPublicJwk(privateKey: KeyObject): HolderPublicJwk { + const jwk = privateKey.export({ format: 'jwk' }) as Record; + + // Export only the public members so the private scalar never leaves the + // local key file. + return parseHolderPublicJwk(jwk); +} + +function generateHolderKey(): KeyObject { + return generateKeyPairSync('ed25519').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): HolderKey { + let stored: StoredHolderKey | undefined; + try { + stored = JSON.parse(readHolderKeyFile(path)) 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 { + privateKey, + publicJwk: toPublicJwk(privateKey), + created: false, + }; + } + + const privateKey = generateHolderKey(); + const payload: StoredHolderKey = { + private_jwk: privateKey.export({ format: 'jwk' }) as Record< + string, + unknown + >, + }; + + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const noFollowFlag = constants.O_NOFOLLOW ?? 0; + let descriptor: number | undefined; + try { + descriptor = openSync( + path, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollowFlag, + 0o600, + ); + writeFileSync(descriptor, `${JSON.stringify(payload, null, 2)}\n`); + fchmodSync(descriptor, 0o600); + } catch (error) { + throw new Error( + `Failed to write holder key at ${path}: ${(error as Error).message}`, + { cause: error }, + ); + } finally { + if (descriptor !== undefined) { + closeSync(descriptor); + } + } + + return { + privateKey, + publicJwk: toPublicJwk(privateKey), + created: true, + }; +} + +/** + * Loads an existing holder key. Does not generate a replacement: a missing + * file is an error so an existing credential cannot be paired with a new key. + */ +export function loadHolderKey(path: string): HolderKey { + let stored: StoredHolderKey; + try { + stored = JSON.parse(readHolderKeyFile(path)) as StoredHolderKey; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error( + `Holder key not found at ${path}. Issue a new credential to create it.`, + ); + } + throw new Error( + `Failed to read holder key at ${path}: ${(error as Error).message}`, + ); + } + + const privateKey = createPrivateKey({ + key: stored.private_jwk as never, + format: 'jwk', + }); + return { + privateKey, + publicJwk: toPublicJwk(privateKey), + created: false, + }; +} diff --git a/packages/cli/src/commands/credentials/index.tsx b/packages/cli/src/commands/credentials/index.tsx new file mode 100644 index 00000000..819418b5 --- /dev/null +++ b/packages/cli/src/commands/credentials/index.tsx @@ -0,0 +1,66 @@ +import { + type IIdentityCredentialsResource, + LinkSdkError, +} from '@stripe/link-sdk'; +import { Cli } from 'incur'; +import { renderInteractive } from '../../utils/render-interactive'; +import { SavedArtifact } from '../identity/saved-artifact'; +import { issueIdentityCredential } from './issue'; +import { writeIdentityCredentialArtifact } from './storage'; + +export function createIdentityCredentialsCli( + createResource: () => IIdentityCredentialsResource, +) { + const cli = Cli.create('credentials', { + description: 'User info that has been signed, proving it comes from Link.', + }); + + cli.command('request', { + description: + 'Request signed user info proving it comes from Link. Includes a wallet of claims such as name, email, and phone that you can present later.', + mcp: false, + outputPolicy: 'agent-only' as const, + async run(c) { + let result: Awaited>; + let outputFile: string; + try { + result = await issueIdentityCredential({ + resource: createResource(), + }); + outputFile = await writeIdentityCredentialArtifact(result); + } catch (error) { + if (error instanceof LinkSdkError) { + throw error; + } + return c.error({ + code: 'INVALID_INPUT', + message: (error as Error).message, + }); + } + + if ( + c.agent || + c.formatExplicit || + process.argv.includes('--full-output') + ) { + return { ...result, output_file: outputFile }; + } + + const humanResult = { + message: 'Identity credential saved', + output_file: outputFile, + expires_at: result.expires_at, + }; + return renderInteractive( + , + () => humanResult, + ); + }, + }); + + 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..8f9623e5 --- /dev/null +++ b/packages/cli/src/commands/credentials/issue.ts @@ -0,0 +1,106 @@ +import { + type HolderPublicJwk, + holderJwksEqual, + holderJwkThumbprint, + type IIdentityCredentialsResource, + parseHolderPublicJwk, +} from '@stripe/link-sdk'; +import { sanitizeDeep } from '../../utils/sanitize-text'; +import { DEFAULT_HOLDER_KEY_PATH, loadOrCreateHolderKey } from './holder-key'; + +export const IDENTITY_CREDENTIAL_ARTIFACT_VERSION = 1 as const; + +export interface IdentityCredentialHolder { + jwk: HolderPublicJwk; + thumbprint: string; + path: string; + created: boolean; +} + +export interface IdentityCredentialIssueResult { + version: typeof IDENTITY_CREDENTIAL_ARTIFACT_VERSION; + credential: string; + issuer: string; + expires_at: string; + holder: IdentityCredentialHolder; + /** Claim names and values recovered from disclosures. Inspection only. */ + claims?: Record; +} + +function decodeJsonSegment(segment: string): unknown { + return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function decodeDisclosedClaims(credential: string): Record { + const [, ...disclosures] = credential.split('~'); + const claims: Record = {}; + + for (const disclosure of disclosures) { + if (!disclosure) { + continue; + } + const parsed = decodeJsonSegment(disclosure); + if (Array.isArray(parsed) && parsed.length === 3) { + claims[String(parsed[1])] = parsed[2]; + } + } + + return claims; +} + +function credentialHolderJwk(credential: string): HolderPublicJwk { + const [issuerJwt] = credential.split('~'); + const payloadSegment = issuerJwt?.split('.')[1]; + if (!payloadSegment) { + throw new Error('Issued credential is not a compact SD-JWT'); + } + const payload = decodeJsonSegment(payloadSegment); + if (!isRecord(payload) || !isRecord(payload.cnf)) { + throw new Error('Issued credential is missing cnf.jwk'); + } + return parseHolderPublicJwk(payload.cnf.jwk); +} + +export async function issueIdentityCredential(options: { + resource: IIdentityCredentialsResource; + keyFile?: string; + includeClaims?: boolean; +}): Promise { + const { + resource, + keyFile = DEFAULT_HOLDER_KEY_PATH, + includeClaims = true, + } = options; + const holderKey = loadOrCreateHolderKey(keyFile); + const publicJwk = holderKey.publicJwk; + + const response = await resource.issue({ + cnf: { jwk: publicJwk }, + }); + const issuedJwk = credentialHolderJwk(response.credential); + if (!holderJwksEqual(issuedJwk, publicJwk)) { + throw new Error( + 'Issued credential cnf.jwk does not match the requested holder public key', + ); + } + + return { + version: IDENTITY_CREDENTIAL_ARTIFACT_VERSION, + credential: response.credential, + issuer: response.issuer, + expires_at: response.expires_at, + holder: { + jwk: publicJwk, + thumbprint: holderJwkThumbprint(publicJwk), + path: keyFile, + created: holderKey.created, + }, + ...(includeClaims + ? { claims: sanitizeDeep(decodeDisclosedClaims(response.credential)) } + : {}), + }; +} diff --git a/packages/cli/src/commands/credentials/storage.ts b/packages/cli/src/commands/credentials/storage.ts new file mode 100644 index 00000000..a97be17f --- /dev/null +++ b/packages/cli/src/commands/credentials/storage.ts @@ -0,0 +1,37 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import Conf from 'conf'; +import type { IdentityCredentialIssueResult } from './issue'; + +function getOutputDirectory(): string { + return path.join(os.homedir(), '.link-cli', 'credentials'); +} + +async function prepareOutputDirectory(): Promise { + const directory = getOutputDirectory(); + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + const stats = await fs.lstat(directory); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error( + `CREDENTIAL_OUTPUT_DIRECTORY_INVALID: ${directory} must be a directory, not a symbolic link`, + ); + } + await fs.chmod(directory, 0o700); + return directory; +} + +export async function writeIdentityCredentialArtifact( + artifact: IdentityCredentialIssueResult, +): Promise { + const directory = await prepareOutputDirectory(); + const store = new Conf({ + projectName: 'link-cli', + cwd: directory, + configName: 'current', + configFileMode: 0o600, + clearInvalidConfig: true, + }); + store.store = artifact; + return store.path; +} diff --git a/packages/cli/src/commands/identity/__tests__/saved-artifact.test.tsx b/packages/cli/src/commands/identity/__tests__/saved-artifact.test.tsx new file mode 100644 index 00000000..58215af5 --- /dev/null +++ b/packages/cli/src/commands/identity/__tests__/saved-artifact.test.tsx @@ -0,0 +1,17 @@ +import { render } from 'ink-testing-library'; +import { expect, it } from 'vitest'; +import { SavedArtifact } from '../saved-artifact'; + +it('renders a consistent success summary for saved identity artifacts', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('✓ Identity credential saved'); + expect(lastFrame()).toContain('Saved to: /tmp/credential.json'); + expect(lastFrame()).toContain('Expires: 2026-09-18T00:00:00Z'); +}); diff --git a/packages/cli/src/commands/identity/index.tsx b/packages/cli/src/commands/identity/index.tsx index e70d6bae..c7868073 100644 --- a/packages/cli/src/commands/identity/index.tsx +++ b/packages/cli/src/commands/identity/index.tsx @@ -1,15 +1,22 @@ -import type { IAttestationsResource } from '@stripe/link-sdk'; +import type { + IAttestationsResource, + IIdentityCredentialsResource, +} from '@stripe/link-sdk'; import { Cli } from 'incur'; import { createAttestationsCli } from '../attestations'; +import { createIdentityCredentialsCli } from '../credentials'; export function createIdentityCli(options: { createAttestationsResource: () => IAttestationsResource; + createIdentityCredentialsResource: () => IIdentityCredentialsResource; }) { const cli = Cli.create('identity', { - description: - 'Privacy-preserving tokens that show Link attests to your agent.', + description: 'Prove your agent and user identity with Link.', }); cli.command(createAttestationsCli(options.createAttestationsResource)); + cli.command( + createIdentityCredentialsCli(options.createIdentityCredentialsResource), + ); return cli; } diff --git a/packages/cli/src/commands/identity/saved-artifact.tsx b/packages/cli/src/commands/identity/saved-artifact.tsx new file mode 100644 index 00000000..ca503f82 --- /dev/null +++ b/packages/cli/src/commands/identity/saved-artifact.tsx @@ -0,0 +1,35 @@ +import { Box, Text } from 'ink'; +import type React from 'react'; + +export interface SavedArtifactDetail { + label: string; + value: string | number; +} + +export function SavedArtifact({ + message, + outputFile, + details = [], +}: { + message: string; + outputFile: string; + details?: SavedArtifactDetail[]; +}): React.ReactElement { + return ( + + ✓ {message} + + + Saved to: + {outputFile} + + {details.map(({ label, value }) => ( + + {label}: + {value} + + ))} + + + ); +} diff --git a/packages/cli/src/utils/__tests__/resource-factory.test.ts b/packages/cli/src/utils/__tests__/resource-factory.test.ts index 9b704821..a52835d5 100644 --- a/packages/cli/src/utils/__tests__/resource-factory.test.ts +++ b/packages/cli/src/utils/__tests__/resource-factory.test.ts @@ -28,6 +28,9 @@ describe('ResourceFactory', () => { expect(factory.createAttestationsResource()).toBe( factory.createAttestationsResource(), ); + expect(factory.createIdentityCredentialsResource()).toBe( + factory.createIdentityCredentialsResource(), + ); expect(factory.createSpendRequestResource()).toBe( factory.createSpendRequestResource(), ); @@ -42,6 +45,9 @@ describe('ResourceFactory', () => { ); expect(factory.createAuthResource()).toBeInstanceOf(LinkAuthResource); expect(factory.createAttestationsResource().request).toBeTypeOf('function'); + expect(factory.createIdentityCredentialsResource().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 8751103b..0f169999 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -2,6 +2,7 @@ import { type AccessTokenProvider, type IAttestationsResource, type IBalancesResource, + type IIdentityCredentialsResource, type IPaymentMethodsResource, type IReportResource, type IShippingAddressResource, @@ -110,6 +111,7 @@ export class ResourceFactory { private accessTokenProvider?: ReturnType; private sdkClient?: Link; private attestationsResource?: IAttestationsResource; + private identityCredentialsResource?: IIdentityCredentialsResource; private spendRequestResource?: ISpendRequestResource; private paymentMethodsResource?: IPaymentMethodsResource; private shippingAddressResource?: IShippingAddressResource; @@ -232,6 +234,18 @@ export class ResourceFactory { return resource; } + createIdentityCredentialsResource(): IIdentityCredentialsResource { + if (this.identityCredentialsResource) { + return this.identityCredentialsResource; + } + + const resource = sanitizeResource( + this.createSdkClient().identityCredentials, + ); + this.identityCredentialsResource = resource; + return resource; + } + createSpendRequestResource(): ISpendRequestResource { if (this.spendRequestResource) { return this.spendRequestResource; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index f87b1b9d..f91563db 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -1,9 +1,11 @@ import type { LinkOptions } from '@/config'; import { AttestationsResource } from '@/resources/attestations'; import { BalancesResource } from '@/resources/balances'; +import { IdentityCredentialsResource } from '@/resources/identity-credentials'; import type { IAttestationsResource, IBalancesResource, + IIdentityCredentialsResource, IPaymentMethodsResource, IReportResource, IShippingAddressResource, @@ -24,6 +26,7 @@ import { WebBotAuthResource } from '@/resources/web-bot-auth'; export class Link { readonly attestations: IAttestationsResource; + readonly identityCredentials: IIdentityCredentialsResource; readonly spendRequests: ISpendRequestResource; readonly paymentMethods: IPaymentMethodsResource; readonly shippingAddresses: IShippingAddressResource; @@ -36,6 +39,7 @@ export class Link { constructor(options: LinkOptions) { this.attestations = new AttestationsResource(options); + this.identityCredentials = new IdentityCredentialsResource(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 ab43ed8d..69c4a811 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -8,6 +8,11 @@ export { LinkTransportError, } from './errors'; export * from './resources/attestations'; +export { + holderJwksEqual, + holderJwkThumbprint, + parseHolderPublicJwk, +} from './resources/holder-jwk'; export * from './resources/interfaces'; export { getDuplicateSpendRequest } from './resources/spend-request'; export * from './types/index'; 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..462331d7 --- /dev/null +++ b/packages/sdk/src/resources/__tests__/credentials.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LinkResponseError } from '@/errors'; +import { IdentityCredentialsResource } from '@/resources/identity-credentials'; + +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('IdentityCredentialsResource', () => { + it('issues through the discovered credential endpoint', async () => { + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === 'https://api.link.com/.well-known/aap-issuer') { + expect(init).toMatchObject({ + method: 'GET', + redirect: 'manual', + }); + return jsonResponse({ + issuer: 'https://api.link.com', + credential_endpoint: 'https://api.link.com/credential', + }); + } + expect(url).toBe('https://api.link.com/credential'); + expect(init?.redirect).toBe('manual'); + 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://api.link.com', + expires_at: '2026-08-25T00:00:00Z', + }); + }, + ); + const resource = new IdentityCredentialsResource({ + apiBaseUrl: 'https://attacker.example', + accessToken: 'access-token', + fetch: fetchMock, + }); + + await expect( + resource.issue({ cnf: { jwk: PUBLIC_JWK } }), + ).resolves.toMatchObject({ + credential: 'issuer-jwt~', + issuer: 'https://api.link.com', + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('rejects metadata for a different issuer before authentication', async () => { + const getAccessToken = vi.fn(async () => 'secret'); + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + jsonResponse({ + issuer: 'https://attacker.example', + credential_endpoint: 'https://api.link.com/credential', + }), + ); + const resource = new IdentityCredentialsResource({ + getAccessToken, + fetch: fetchMock, + }); + + await expect( + resource.issue({ cnf: { jwk: PUBLIC_JWK } }), + ).rejects.toBeInstanceOf(LinkResponseError); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getAccessToken).not.toHaveBeenCalled(); + }); + + 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://api.link.com', + credential_endpoint: 'https://attacker.example/credential', + }), + ); + const resource = new IdentityCredentialsResource({ + 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 IdentityCredentialsResource({ + 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://api.link.com', + credential_endpoint: 'https://api.link.com/credential', + }) + : jsonResponse({ credential: 42 }), + ); + const resource = new IdentityCredentialsResource({ + accessToken: 'access-token', + fetch: fetchMock, + }); + + await expect( + resource.issue({ cnf: { jwk: PUBLIC_JWK } }), + ).rejects.toBeInstanceOf(LinkResponseError); + }); + + it('rejects a credential response from a different issuer', async () => { + const fetchMock = vi.fn( + async (input: RequestInfo | URL, _init?: RequestInit) => + String(input).endsWith('/.well-known/aap-issuer') + ? jsonResponse({ + issuer: 'https://api.link.com', + credential_endpoint: 'https://api.link.com/credential', + }) + : jsonResponse({ + credential: 'issuer-jwt~', + issuer: 'https://attacker.example', + expires_at: '2026-08-25T00:00:00Z', + }), + ); + const resource = new IdentityCredentialsResource({ + accessToken: 'access-token', + fetch: fetchMock, + }); + + await expect( + resource.issue({ cnf: { jwk: PUBLIC_JWK } }), + ).rejects.toBeInstanceOf(LinkResponseError); + }); + + it('sends only public JWK members', async () => { + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith('/.well-known/aap-issuer')) { + return jsonResponse({ + issuer: 'https://api.link.com', + credential_endpoint: 'https://api.link.com/credential', + }); + } + expect(JSON.parse(String(init?.body))).toEqual({ + cnf: { jwk: PUBLIC_JWK }, + }); + return jsonResponse({ + credential: 'issuer-jwt~', + issuer: 'https://api.link.com', + expires_at: '2026-08-25T00:00:00Z', + }); + }, + ); + const resource = new IdentityCredentialsResource({ + accessToken: 'access-token', + fetch: fetchMock, + }); + + await expect( + resource.issue({ + cnf: { jwk: { ...PUBLIC_JWK, d: 'private' } as never }, + }), + ).resolves.toMatchObject({ issuer: 'https://api.link.com' }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + 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://api.link.com', + credential_endpoint: 'https://api.link.com/credential', + }) + : jsonResponse({ error: 'unauthorized' }, 401), + ); + const getAccessToken = vi.fn( + ({ forceRefresh }: { forceRefresh?: boolean } = {}) => + forceRefresh ? 'refreshed-token' : 'initial-token', + ); + const resource = new IdentityCredentialsResource({ + getAccessToken, + fetch: fetchMock, + }); + + await expect(resource.issue({ cnf: { jwk: PUBLIC_JWK } })).rejects.toThrow( + 'Failed to issue identity credential (401)', + ); + expect(getAccessToken).toHaveBeenNthCalledWith(1); + 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 7a02eb09..eac98ec9 100644 --- a/packages/sdk/src/resources/__tests__/factory.test.ts +++ b/packages/sdk/src/resources/__tests__/factory.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import Link from '@/client'; import { AttestationsResource } from '@/resources/attestations'; +import { IdentityCredentialsResource } from '@/resources/identity-credentials'; import { PaymentMethodsResource } from '@/resources/payment-methods'; import { ReportResource } from '@/resources/report'; import { SpendRequestResource } from '@/resources/spend-request'; @@ -16,6 +17,9 @@ describe('Link', () => { }); expect(client.attestations).toBeInstanceOf(AttestationsResource); + expect(client.identityCredentials).toBeInstanceOf( + IdentityCredentialsResource, + ); expect(client.spendRequests).toBeInstanceOf(SpendRequestResource); expect(client.paymentMethods).toBeInstanceOf(PaymentMethodsResource); expect(client.transactions).toBeInstanceOf(TransactionsResource); @@ -25,6 +29,7 @@ describe('Link', () => { expect(client.spendRequests.update).toBeTypeOf('function'); expect(client.spendRequests.retrieve).toBeTypeOf('function'); expect(client.attestations.request).toBeTypeOf('function'); + expect(client.identityCredentials.issue).toBeTypeOf('function'); expect(client.paymentMethods.list).toBeTypeOf('function'); expect(client.transactions.list).toBeTypeOf('function'); }); diff --git a/packages/sdk/src/resources/__tests__/holder-jwk.test.ts b/packages/sdk/src/resources/__tests__/holder-jwk.test.ts new file mode 100644 index 00000000..bd81e2c6 --- /dev/null +++ b/packages/sdk/src/resources/__tests__/holder-jwk.test.ts @@ -0,0 +1,49 @@ +import { generateKeyPairSync } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { + holderJwksEqual, + holderJwkThumbprint, + parseHolderPublicJwk, +} from '@/resources/holder-jwk'; + +function ed25519PublicJwk(): { kty: 'OKP'; crv: 'Ed25519'; x: string } { + const { publicKey } = generateKeyPairSync('ed25519'); + const jwk = publicKey.export({ format: 'jwk' }) as { x: string }; + return { kty: 'OKP', crv: 'Ed25519', x: jwk.x }; +} + +describe('parseHolderPublicJwk', () => { + it('accepts an Ed25519 public JWK and strips extra members', () => { + const jwk = ed25519PublicJwk(); + expect( + parseHolderPublicJwk({ ...jwk, alg: 'EdDSA', kid: 'unused' }), + ).toEqual(jwk); + }); + + it('normalizes a private JWK to its public members', () => { + const jwk = ed25519PublicJwk(); + expect(parseHolderPublicJwk({ ...jwk, d: 'private' })).toEqual(jwk); + }); + + it('rejects unsupported key types', () => { + expect(() => + parseHolderPublicJwk({ + kty: 'EC', + crv: 'P-256', + x: 'x', + y: 'y', + }), + ).toThrow('Ed25519 OKP'); + }); +}); + +describe('holderJwkThumbprint', () => { + it('is RFC 7638 SHA-256 base64url and distinguishes keys', () => { + const left = ed25519PublicJwk(); + const right = ed25519PublicJwk(); + const thumbprint = holderJwkThumbprint(left); + expect(thumbprint).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(holderJwksEqual(left, left)).toBe(true); + expect(holderJwksEqual(left, right)).toBe(false); + }); +}); diff --git a/packages/sdk/src/resources/base.ts b/packages/sdk/src/resources/base.ts index ef24d68b..3bb1c0a6 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) { @@ -120,8 +122,13 @@ export abstract class BaseResource { if (res.status === 401 && this.canRefreshAccessToken) { const refreshedToken = await this.getAccessToken({ forceRefresh: true }); - authedOpts.headers.Authorization = `Bearer ${refreshedToken}`; - return this.rawFetch(authedOpts); + return this.rawFetch({ + ...opts, + headers: { + ...opts.headers, + Authorization: `Bearer ${refreshedToken}`, + }, + }); } return res; diff --git a/packages/sdk/src/resources/holder-jwk.ts b/packages/sdk/src/resources/holder-jwk.ts new file mode 100644 index 00000000..07ade926 --- /dev/null +++ b/packages/sdk/src/resources/holder-jwk.ts @@ -0,0 +1,50 @@ +import { createHash } from 'node:crypto'; +import { LinkConfigurationError } from '@/errors'; +import type { HolderPublicJwk } from '@/resources/interfaces'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * RFC 7638 SHA-256 thumbprint of a holder public JWK, base64url-encoded. + */ +export function holderJwkThumbprint(jwk: HolderPublicJwk): string { + const canonical = JSON.stringify({ + crv: jwk.crv, + kty: jwk.kty, + x: jwk.x, + }); + return createHash('sha256').update(canonical).digest('base64url'); +} + +export function holderJwksEqual( + left: HolderPublicJwk, + right: HolderPublicJwk, +): boolean { + return holderJwkThumbprint(left) === holderJwkThumbprint(right); +} + +/** + * Normalizes a supported holder JWK to its public members. + */ +export function parseHolderPublicJwk(value: unknown): HolderPublicJwk { + if (!isRecord(value)) { + throw new LinkConfigurationError('Holder public key must be a JWK object'); + } + if (typeof value.kty !== 'string') { + throw new LinkConfigurationError('Holder public key is missing kty'); + } + + if ( + value.kty !== 'OKP' || + value.crv !== 'Ed25519' || + typeof value.x !== 'string' || + value.x.length === 0 + ) { + throw new LinkConfigurationError( + 'Holder public key must be an Ed25519 OKP JWK with an x member', + ); + } + return { kty: 'OKP', crv: 'Ed25519', x: value.x }; +} diff --git a/packages/sdk/src/resources/identity-credentials.ts b/packages/sdk/src/resources/identity-credentials.ts new file mode 100644 index 00000000..31f05483 --- /dev/null +++ b/packages/sdk/src/resources/identity-credentials.ts @@ -0,0 +1,122 @@ +import { z } from 'zod'; +import type { LinkOptions } from '@/config'; +import { LinkApiError } from '@/errors'; +import { BaseResource } from '@/resources/base'; +import { parseHolderPublicJwk } from '@/resources/holder-jwk'; +import type { + IIdentityCredentialsResource, + IssueIdentityCredentialParams, + IssueIdentityCredentialResponse, +} from '@/resources/interfaces'; + +const LINK_ISSUER = 'https://api.link.com'; +const LINK_ISSUER_METADATA_URL = `${LINK_ISSUER}/.well-known/aap-issuer`; + +const identityCredentialIssuerMetadataSchema = z.looseObject({ + issuer: z.literal(LINK_ISSUER), + credential_endpoint: z.string(), +}); + +const issueIdentityCredentialResponseSchema = z.looseObject({ + credential: z.string(), + issuer: z.literal(LINK_ISSUER), + expires_at: z.string(), +}); + +/** Accepts a discovered endpoint only when it remains on api.link.com. */ +function requireLinkEndpoint(value: 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 }); + } + if ( + url.protocol !== 'https:' || + url.origin !== LINK_ISSUER || + url.username || + url.password + ) { + throw new TypeError(`${field} must be an HTTPS URL on ${LINK_ISSUER}`); + } + return url.href; +} + +export class IdentityCredentialsResource + extends BaseResource + implements IIdentityCredentialsResource +{ + constructor(options: LinkOptions) { + super(options, ''); + } + + private async discoverCredentialEndpoint(): Promise { + const { status, data, rawBody } = await this.rawFetch({ + method: 'GET', + url: LINK_ISSUER_METADATA_URL, + redirect: 'manual', + }); + if (status >= 300 && status < 400) { + throw new LinkApiError( + `Refused redirect while fetching identity credential issuer metadata (${status})`, + { status, rawBody }, + ); + } + + if (status < 200 || status >= 300) { + this.throwApiError( + 'fetch identity credential issuer metadata', + status, + data, + rawBody, + ); + } + + const metadata = this.parseResponse( + 'parse identity credential issuer metadata', + status, + () => identityCredentialIssuerMetadataSchema.parse(data), + ); + return this.parseResponse( + 'validate identity credential issuer metadata', + status, + () => + requireLinkEndpoint( + metadata.credential_endpoint, + 'credential_endpoint', + ), + ); + } + + async issue( + params: IssueIdentityCredentialParams, + ): Promise { + const publicJwk = parseHolderPublicJwk(params.cnf.jwk); + const endpoint = await this.discoverCredentialEndpoint(); + const { status, data, rawBody } = await this.apiFetch({ + method: 'POST', + url: endpoint, + redirect: 'manual', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ cnf: { jwk: publicJwk } }), + }); + + if (status >= 300 && status < 400) { + throw new LinkApiError( + `Refused redirect while issuing identity credential (${status})`, + { status, rawBody }, + ); + } + + if (status < 200 || status >= 300) { + this.throwApiError('issue identity credential', status, data, rawBody); + } + + return this.parseResponse('issue identity credential', status, () => + issueIdentityCredentialResponseSchema.parse(data), + ); + } +} diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index 782ec592..11bb7ca6 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -38,6 +38,24 @@ export interface IAttestationsResource { request(params: AttestationRequestParams): Promise; } +export type HolderPublicJwk = { kty: 'OKP'; crv: 'Ed25519'; x: string }; + +export interface IssueIdentityCredentialParams { + cnf: { jwk: HolderPublicJwk }; +} + +export interface IssueIdentityCredentialResponse { + credential: string; + issuer: string; + expires_at: string; +} + +export interface IIdentityCredentialsResource { + issue( + params: IssueIdentityCredentialParams, + ): Promise; +} + export interface CreateSpendRequestParams { idempotency_key?: string; payment_details?: string;