From 68e71a1404721ba5a2c2ea58717f05d3daa46849 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 7 Oct 2026 20:30:39 -0700 Subject: [PATCH] Remove the unused identity-token minting The token-based identity path is retired, so the minting, request signing and presenting modules, the agent key path, and their tests are removed. Takes @agent-score/sdk ^2.9.0. Minor bump: no command used it. --- .claude/CLAUDE.md | 2 +- bun.lock | 4 +- knip.json | 3 +- package.json | 4 +- src/aip/agent-key.ts | 120 --------------- src/aip/http-signature.ts | 159 -------------------- src/aip/mint.ts | 116 --------------- src/aip/present.ts | 52 ------- src/aip/presenter.ts | 108 -------------- src/commands/pay.ts | 10 -- src/passport/attach.ts | 2 +- src/paths.ts | 7 - tests/aip_agent_key.test.ts | 75 ---------- tests/aip_http_signature.test.ts | 146 ------------------ tests/aip_mint.test.ts | 213 --------------------------- tests/aip_presenter.test.ts | 86 ----------- tests/aip_request_descriptor.test.ts | 29 ---- 17 files changed, 7 insertions(+), 1129 deletions(-) delete mode 100644 src/aip/agent-key.ts delete mode 100644 src/aip/http-signature.ts delete mode 100644 src/aip/mint.ts delete mode 100644 src/aip/present.ts delete mode 100644 src/aip/presenter.ts delete mode 100644 tests/aip_agent_key.test.ts delete mode 100644 tests/aip_http_signature.test.ts delete mode 100644 tests/aip_mint.test.ts delete mode 100644 tests/aip_presenter.test.ts delete mode 100644 tests/aip_request_descriptor.test.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 2512fa3..5486cb3 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -49,7 +49,7 @@ Both paths preserve POST bodies through the 402 round-trip. The CLI's passphrase - **ESLint 9**, linting. `bun run lint`. - **tsup**, builds ESM with `#!/usr/bin/env node` banner. `bun run build`. - **Vitest**, tests. `bun run test`. -- **knip**, dead-code check. `bun run knip` (src/aip/ excluded: dormant surface kept for re-enable). +- **knip**, dead-code check. `bun run knip`. - **Lefthook**, git hooks. Pre-commit: lint. Pre-push: typecheck. ## Patched dependencies diff --git a/bun.lock b/bun.lock index a0112a4..0db9d46 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@agent-score/pay", "dependencies": { - "@agent-score/sdk": "^2.8.1", + "@agent-score/sdk": "^2.9.0", "@clack/prompts": "^1.8.1", "@scure/bip39": "^2.4.0", "@solana-program/system": "^0.15.0", @@ -52,7 +52,7 @@ "packages": { "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], - "@agent-score/sdk": ["@agent-score/sdk@2.8.1", "", {}, "sha512-TX+pTdWq597+rS2zPfdUCsSeZQcBHHG3QLkqUsz74HPltlAK+jZQgPTZ6Dr/bFkAJV2vLG5l27zAYZbsVZyezw=="], + "@agent-score/sdk": ["@agent-score/sdk@2.9.0", "", {}, "sha512-O3/pXJhkjOjNZc+3VBR6se7g+FIXSLepzr2IMd0+as3DhkWnFu91Qocg2ijy1l75OQMtRIMZjOITEGNigOgjZw=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], diff --git a/knip.json b/knip.json index 0b8a958..27a8c0f 100644 --- a/knip.json +++ b/knip.json @@ -1,4 +1,3 @@ { - "$schema": "https://unpkg.com/knip@6/schema.json", - "ignore": ["src/aip/**"] + "$schema": "https://unpkg.com/knip@6/schema.json" } diff --git a/package.json b/package.json index 0c4cc90..440cc69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/pay", - "version": "0.6.5", + "version": "0.7.0", "description": "CLI wallet for one-shell-command agent payments across x402 (Base) and MPP (Tempo, Solana)", "type": "module", "main": "./dist/index.js", @@ -58,7 +58,7 @@ "bun": ">=1.3.0 <2" }, "dependencies": { - "@agent-score/sdk": "^2.8.1", + "@agent-score/sdk": "^2.9.0", "@clack/prompts": "^1.8.1", "@scure/bip39": "^2.4.0", "@solana-program/system": "^0.15.0", diff --git a/src/aip/agent-key.ts b/src/aip/agent-key.ts deleted file mode 100644 index cf4a7cb..0000000 --- a/src/aip/agent-key.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * AIP agent key, the Ed25519 keypair pay uses as the AIT `cnf` (proof-of-possession) key. - * - * One Ed25519 key, independent of payment wallets (which are chain-typed secp256k1/ed25519). The - * public half is bound into a minted AIT's `cnf` claim; the private half signs each merchant - * request (RFC 9421) to prove possession. Stored encrypted at {@link agentKeyPath} with the same - * AES-256-GCM + scrypt envelope as the wallet keystore. - * - * Only the private scalar (`d`) is persisted (encrypted). The public JWK + keyid thumbprint are - * derived on load and are safe to display/transmit. - */ -import { createPrivateKey, generateKeyPairSync, type KeyObject } from 'crypto'; -import { mkdir, readFile, writeFile } from 'fs/promises'; -import { dirname } from 'path'; -import { CliError } from '../errors'; -import { encryptSecret, decryptSecret, type KeystoreFile } from '../keystore'; -import { agentKeyPath, baseDir } from '../paths'; -import type { Ed25519PublicJwk } from './http-signature'; - -interface AgentKeyFile { - version: 1; - kind: 'aip-agent-key'; - crv: 'Ed25519'; - /** Public x coordinate (base64url), kept in the clear for quick public-JWK reads. */ - x: string; - /** Encrypted private scalar `d` (the 32-byte Ed25519 seed). */ - encryption: KeystoreFile['encryption']; -} - -export interface AgentKey { - /** node:crypto private KeyObject for signing. */ - privateKey: KeyObject; - /** Public cnf JWK to embed in the AIT mint request. */ - publicJwk: Ed25519PublicJwk; -} - -const isNotFound = (err: unknown): boolean => - typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT'; - -/** True if an agent key already exists on disk. Only ENOENT means "missing", any other - * error (EACCES, EIO, ...) propagates so a transient failure never silently regenerates - * (and overwrites) the key. */ -export async function agentKeyExists(): Promise { - try { - await readFile(agentKeyPath(), 'utf-8'); - return true; - } catch (err: unknown) { - if (isNotFound(err)) return false; - throw err; - } -} - -async function loadAgentKey(passphrase: string): Promise { - const raw = await readFile(agentKeyPath(), 'utf-8'); - const file = JSON.parse(raw) as AgentKeyFile; - if (file.version !== 1 || file.kind !== 'aip-agent-key') { - throw new Error('Unsupported AIP agent-key file'); - } - let d: string; - try { - d = (await decryptSecret(file.encryption, passphrase)).toString('base64url'); - } catch { - throw new CliError( - 'wrong_passphrase', - `Could not decrypt the AIP agent key at ${agentKeyPath()}, the passphrase does not match the one the key was created with.`, - { - nextSteps: { - action: 'retry_or_regenerate_agent_key', - suggestion: `Retry with the passphrase the agent key was created under, or delete ${agentKeyPath()} to regenerate it, the cnf public key is re-sent on every mint, so deleting it is safe.`, - }, - extra: { path: agentKeyPath() }, - }, - ); - } - const privateKey = createPrivateKey({ - key: { kty: 'OKP', crv: 'Ed25519', x: file.x, d }, - format: 'jwk', - }); - return { privateKey, publicJwk: { kty: 'OKP', crv: 'Ed25519', x: file.x } }; -} - -async function createAgentKey(passphrase: string): Promise { - const { privateKey, publicKey } = generateKeyPairSync('ed25519'); - const privJwk = privateKey.export({ format: 'jwk' }) as { x: string; d: string }; - const seed = Buffer.from(privJwk.d, 'base64url'); - const encryption = await encryptSecret(seed, passphrase); - const file: AgentKeyFile = { - version: 1, - kind: 'aip-agent-key', - crv: 'Ed25519', - x: privJwk.x, - encryption, - }; - const path = agentKeyPath(); - await mkdir(baseDir(), { recursive: true, mode: 0o700 }); - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - try { - // Exclusive create: never clobber a key another process persisted between the exists - // check and this write, losing the create race means the other key is canonical. - await writeFile(path, JSON.stringify(file, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); - } catch (err: unknown) { - if ((err as { code?: string } | null)?.code === 'EEXIST') return loadAgentKey(passphrase); - throw err; - } - void publicKey; // public material is derived from the stored JWK on load - return { privateKey, publicJwk: { kty: 'OKP', crv: 'Ed25519', x: privJwk.x } }; -} - -/** - * Load the agent key, creating + persisting one on first use. `passphrase` unlocks (or encrypts, - * on first create) the private scalar, same passphrase model as the wallet keystore. - */ -export async function getOrCreateAgentKey(passphrase: string): Promise { - try { - if (await agentKeyExists()) return await loadAgentKey(passphrase); - } catch (err) { - if (!isNotFound(err)) throw err; - } - return createAgentKey(passphrase); -} diff --git a/src/aip/http-signature.ts b/src/aip/http-signature.ts deleted file mode 100644 index 0ce5ca1..0000000 --- a/src/aip/http-signature.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * RFC 9421 HTTP Message Signature, the AIP proof-of-possession SIGNER (client side). - * - * pay presents an Agent Identity Token (AIT) to a merchant by signing the request with the - * Ed25519 key whose public half is bound in the token's `cnf` claim. This module produces the - * `Signature-Input` + `Signature` headers the merchant's verifier checks. - * - * It is a self-contained port of the serialization in the AgentScore commerce verifier - * (`the AgentScore commerce verifier`): pay must NOT depend on the heavy merchant SDK, so - * the wire format is reproduced here with only `node:crypto`. A conformance test - * (`tests/aip_http_signature.test.ts`) pins this byte-for-byte against that verifier, so any drift - * fails CI. - * - * Covered components (canonical order): @method @authority @path agent-identity; tag - * "agent-identity"; keyid = RFC 7638 thumbprint of the public cnf JWK; alg Ed25519. - */ -import { createHash, type KeyObject, sign as nodeSign } from 'crypto'; - -/** The AIP "minimum required" covered components, in canonical order. */ -export const AIP_COVERED_COMPONENTS = ['@method', '@authority', '@path', 'agent-identity'] as const; - -/** Tag that identifies the AIP signature among coexisting RFC 9421 signatures. */ -export const AIP_SIGNATURE_TAG = 'agent-identity'; - -/** Default signature validity window (seconds). The AIP spec presents `created` + `expires` with a - * recommended ~60s window; emit `expires = created + 60` so the signature carries an explicit expiry. */ -const DEFAULT_SIGNATURE_TTL_SECONDS = 60; - -/** Public Ed25519 JWK (the form bound in `cnf` and thumbprinted for keyid). */ -export interface Ed25519PublicJwk { - kty: 'OKP'; - crv: 'Ed25519'; - x: string; -} - -interface SignatureParams { - components: string[]; - created?: number; - expires?: number; - keyid: string; - tag: string; -} - -/** - * RFC 7638 JWK thumbprint of an Ed25519 public JWK: sha256 over the canonical JSON - * `{"crv","kty","x"}` (lexicographic, no whitespace), base64url. Matches jose's - * `calculateJwkThumbprint(jwk,'sha256')` byte-for-byte (verified). - */ -export function jwkThumbprint(jwk: Ed25519PublicJwk): string { - const canonical = `{"crv":"${jwk.crv}","kty":"${jwk.kty}","x":"${jwk.x}"}`; - return createHash('sha256').update(canonical).digest('base64url'); -} - -/** - * Normalize an authority for `@authority` per RFC 9421 §2.2.3: lowercase, drop the default port. - * Scheme is unknown here, so drop the common defaults (80/443). Mirrors the verifier exactly. - */ -function normalizeAuthority(authority: string): string { - const lower = authority.trim().toLowerCase(); - const colon = lower.lastIndexOf(':'); - if (colon === -1) return lower; - if (lower.includes(']') && colon < lower.indexOf(']')) return lower; // IPv6 literal, no port - const port = lower.slice(colon + 1); - if (port === '80' || port === '443') return lower.slice(0, colon); - return lower; -} - -function componentValue(name: string, input: SignInput): string { - switch (name) { - case '@method': - return input.method.toUpperCase(); - case '@authority': - return normalizeAuthority(input.authority); - case '@path': - return input.path; - case 'agent-identity': - return input.agentIdentity.trim(); - default: - throw new Error(`unsupported covered component: ${name}`); - } -} - -const serializeComponentList = (components: string[]): string => - `(${components.map((c) => `"${c}"`).join(' ')})`; - -/** Serialize the `;k=v` params suffix in canonical order (must match the verifier). */ -function serializeParams(p: SignatureParams): string { - const parts: string[] = []; - if (p.created !== undefined) parts.push(`created=${p.created}`); - if (p.expires !== undefined) parts.push(`expires=${p.expires}`); - parts.push(`keyid="${p.keyid}"`); - parts.push(`tag="${p.tag}"`); - return parts.map((s) => `;${s}`).join(''); -} - -/** - * Build the RFC 9421 signature base: one line per covered component, then the - * `@signature-params` line. Joined by `\n`, no trailing newline. - */ -function buildSignatureBase(params: SignatureParams, input: SignInput): string { - const lines = params.components.map((name) => `"${name}": ${componentValue(name, input)}`); - lines.push(`"@signature-params": ${serializeComponentList(params.components)}${serializeParams(params)}`); - return lines.join('\n'); -} - -interface SignInput { - method: string; - authority: string; - path: string; - agentIdentity: string; -} - -export interface SignAitRequestInput extends SignInput { - /** Ed25519 private key (node:crypto KeyObject) whose public half is the cnf key. */ - privateKey: KeyObject; - /** Public cnf JWK (used to derive the keyid thumbprint). */ - publicJwk: Ed25519PublicJwk; - /** Signature creation time (unix seconds); defaults to now. */ - created?: number; - /** Optional expiry (unix seconds). */ - expires?: number; - /** Signature dictionary label; defaults to "ait". */ - label?: string; -} - -/** - * Sign an outgoing merchant request for AIT proof-of-possession. Returns the three headers to - * attach: the AIT itself plus the RFC 9421 `Signature-Input` / `Signature`. - */ -export function signAitRequest(input: SignAitRequestInput): { - agentIdentity: string; - signatureInput: string; - signature: string; -} { - const label = input.label ?? 'ait'; - const components = [...AIP_COVERED_COMPONENTS]; - const created = input.created ?? Math.floor(Date.now() / 1000); - const expires = input.expires ?? created + DEFAULT_SIGNATURE_TTL_SECONDS; - const keyid = jwkThumbprint(input.publicJwk); - - const params: SignatureParams = { - components, - created, - expires, - keyid, - tag: AIP_SIGNATURE_TAG, - }; - - const base = buildSignatureBase(params, input); - // Ed25519 (EdDSA): node:crypto sign with a null algorithm over the raw base bytes. - const sigBytes = nodeSign(null, Buffer.from(base, 'utf8'), input.privateKey); - const b64 = sigBytes.toString('base64'); - - return { - agentIdentity: input.agentIdentity, - signatureInput: `${label}=${serializeComponentList(components)}${serializeParams(params)}`, - signature: `${label}=:${b64}:`, - }; -} diff --git a/src/aip/mint.ts b/src/aip/mint.ts deleted file mode 100644 index 14cf8cb..0000000 --- a/src/aip/mint.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Mint an AgentScore Agent Identity Token (AIT) from the passport `operator_token`. - * - * pay sends X-Client-Id `agentscore_pay_pubclient_v1` when minting. - * The issuer resolves the operator's verified identity from its passport/account and stamps the - * derived compliance claims (id_verified, age bands, jurisdiction, sanctions), pay does NOT and - * cannot assert those. pay supplies only the operator_token, the agent cnf public key, and an - * honest `trust_level` reflecting whether a human is at the terminal. - * - * Mirrors the conventions in `passport/auth.ts` (X-Client-Id + User-Agent headers, baseUrl - * normalization, injectable fetch, AgentScoreError mapping). - */ -import { AgentScoreError } from '@agent-score/sdk'; -import { createSecureFetch } from '../url-security'; -import type { Ed25519PublicJwk } from './http-signature'; - -const DEFAULT_BASE_URL = process.env.AGENTSCORE_BASE_URL ?? 'https://api.agentscore.com'; -const PUBLIC_CLIENT_ID = 'agentscore_pay_pubclient_v1'; - -/** AIP trust level, the per-action human-authorization strength. `autonomous` (no human) and - * `human_present` (TTY) are inferred from terminal presence; `human_confirmed` is set only when the - * human explicitly confirms the intent at the terminal (requires auth.amr + intent.description). */ -export type TrustLevel = 'autonomous' | 'human_present' | 'human_confirmed'; - -export interface MintAitInput { - operatorToken: string; - cnfJwk: Ed25519PublicJwk; - /** Agent platform identifier for the `agent.provider` claim (e.g. "anthropic"). */ - provider: string; - trustLevel: TrustLevel; - /** Optional human-readable purpose, stamped into the token's `intent.description` claim. */ - intent?: string; - /** Machine-readable intent actions (e.g. `['purchase']`), stamped into `intent.actions`. */ - actions?: string[]; - /** Authentication context for `human_confirmed`, `auth.amr` (RFC 8176) MUST carry ≥1 value - * (e.g. `['user']` for a terminal confirmation). The issuer rejects human_confirmed without it. */ - auth?: { amr?: string[]; time?: number }; - baseUrl?: string; - fetch?: typeof globalThis.fetch; -} - -export interface MintedAit { - /** The signed AIT (JWT). */ - token: string; - /** Seconds until expiry (short-lived, ~300s). */ - expiresIn: number; -} - -/** - * POST {baseUrl}/v1/agent-identity/token. Returns the minted AIT on 201; throws AgentScoreError - * (with the issuer's error code) otherwise. - */ -export async function mintAit(input: MintAitInput): Promise { - // `||` (not `??`): an empty/whitespace baseUrl falls back to the default rather than producing a - // relative request URL. mintAit is exported, so guard against a caller passing ''. - const baseUrl = ((input.baseUrl && input.baseUrl.trim()) || DEFAULT_BASE_URL).replace(/\/+$/, ''); - // The request body carries the bearer operator_token; pin redirects to the issuer origin so a - // 307/308 can never re-send it elsewhere (undici re-issues bodies on cross-origin redirects). - const fetchImpl = createSecureFetch({ fetch: input.fetch ?? globalThis.fetch }); - - // Combine intent.description (--intent) + intent.actions into one `intent` claim, omitted entirely - // when neither is present. - const intentClaim: { description?: string; actions?: string[] } = { - ...(input.intent && input.intent.trim() ? { description: input.intent.trim() } : {}), - ...(input.actions && input.actions.length > 0 ? { actions: input.actions } : {}), - }; - - const response = await fetchImpl(`${baseUrl}/v1/agent-identity/token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': '@agent-score/pay', - 'X-Client-Id': PUBLIC_CLIENT_ID, - }, - body: JSON.stringify({ - operator_token: input.operatorToken, - cnf_jwk: input.cnfJwk, - agent: { provider: input.provider }, - trust_level: input.trustLevel, - ...(input.auth ? { auth: input.auth } : {}), - ...(Object.keys(intentClaim).length > 0 ? { intent: intentClaim } : {}), - }), - }); - - const body = await response.text(); - if (!response.ok) { - let parsed: { error?: { code?: string; message?: string } } | null = null; - try { - parsed = body ? JSON.parse(body) : null; - } catch { - parsed = null; - } - throw new AgentScoreError( - parsed?.error?.code ?? 'http_error', - parsed?.error?.message ?? `HTTP ${response.status}`, - response.status, - ); - } - - let result: { token?: unknown; token_type?: unknown; expires_in?: unknown }; - try { - result = JSON.parse(body) as typeof result; - } catch { - throw new AgentScoreError('invalid_response', 'Issuer returned a non-JSON mint response.', response.status); - } - if (typeof result.token !== 'string' || result.token.length === 0) { - throw new AgentScoreError('invalid_response', 'Issuer mint response is missing the token.', response.status); - } - const expiresIn = typeof result.expires_in === 'number' ? result.expires_in : 0; - return { token: result.token, expiresIn }; -} - -/** Honest trust level from terminal presence: TTY → human_present, else autonomous. */ -export function inferTrustLevel(): TrustLevel { - return process.stdin.isTTY && process.stdout.isTTY ? 'human_present' : 'autonomous'; -} diff --git a/src/aip/present.ts b/src/aip/present.ts deleted file mode 100644 index be8dd40..0000000 --- a/src/aip/present.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * High-level AIP presentation: mint an AIT bound to pay's agent key, then sign a specific outgoing - * request so a merchant can verify proof-of-possession. Ties together the agent key, the mint - * client, and the RFC 9421 signer. - */ -import { createAipPresenter } from './presenter'; -import type { TrustLevel } from './mint'; - -export interface PresentAitInput { - operatorToken: string; - passphrase: string; - /** The request the AIT will be presented on (its method/authority/path are signed). */ - request: { method: string; authority: string; path: string }; - provider?: string; - /** Override the inferred trust level (default: TTY → human_present, else autonomous). */ - trustLevel?: TrustLevel; - /** Optional intent description carried in the minted token. */ - intent?: string; - baseUrl?: string; - fetch?: typeof globalThis.fetch; -} - -export interface PresentedAit { - /** Headers to attach to the merchant request. */ - headers: { 'Agent-Identity': string; 'Signature-Input': string; Signature: string }; - /** Seconds until the AIT expires. */ - expiresIn: number; - trustLevel: TrustLevel; -} - -/** - * Mint a fresh AIT and produce the three headers (`Agent-Identity`, `Signature-Input`, - * `Signature`) to present it on `request`. AITs are short-lived, so this mints on demand rather - * than persisting a token. - */ -export async function presentAit(input: PresentAitInput): Promise { - const presenter = await createAipPresenter({ - operatorToken: input.operatorToken, - passphrase: input.passphrase, - ...(input.provider !== undefined && { provider: input.provider }), - ...(input.trustLevel !== undefined && { trustLevel: input.trustLevel }), - ...(input.intent !== undefined && { intent: input.intent }), - ...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }), - ...(input.fetch !== undefined && { fetch: input.fetch }), - }); - - return { - headers: presenter.sign(input.request), - expiresIn: presenter.expiresIn, - trustLevel: presenter.trustLevel, - }; -} diff --git a/src/aip/presenter.ts b/src/aip/presenter.ts deleted file mode 100644 index acceb04..0000000 --- a/src/aip/presenter.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * AIP presenter for the `pay` flow: mint an Agent Identity Token ONCE, then re-sign the outgoing - * request per attempt. - * - * The AIT itself is valid for its full TTL (~300s), but the RFC 9421 proof-of-possession signature - * carries a short `created`/`expires` window (~60s). The payment round-trip (discovery 402 + - * settle) and any network retry can span more than one signing window, so we mint the token once - * (the network call) and hand back a `sign()` closure that produces fresh signature headers for - * each attempt locally (no extra network, no re-mint). - */ -import { getOrCreateAgentKey } from './agent-key'; -import { signAitRequest } from './http-signature'; -import { inferTrustLevel, mintAit, type TrustLevel } from './mint'; - -export interface AipRequestDescriptor { - method: string; - authority: string; - path: string; -} - -export interface AipPresenterOptions { - operatorToken: string; - passphrase: string; - /** Agent platform for the `agent.provider` claim (default: agentscore-pay). */ - provider?: string; - /** Override the inferred trust level (default: TTY → human_present, else autonomous). */ - trustLevel?: TrustLevel; - /** Optional intent description carried in the minted token (see `--intent`). */ - intent?: string; - /** Machine-readable intent actions (e.g. `['purchase']`) carried in `intent.actions`. */ - actions?: string[]; - /** Authentication context for `human_confirmed` (`auth.amr` MUST carry ≥1 value). */ - auth?: { amr?: string[]; time?: number }; - baseUrl?: string; - fetch?: typeof globalThis.fetch; -} - -export type AipHeaders = { 'Agent-Identity': string; 'Signature-Input': string; Signature: string }; - -export interface AipPresenter { - /** Seconds until the AIT expires. */ - expiresIn: number; - trustLevel: TrustLevel; - /** Issuer (`iss`) decoded from the minted token, for display/dry-run visibility. */ - issuer?: string; - /** Subject (`sub`) decoded from the minted token, for display/dry-run visibility. */ - subject?: string; - /** Sign `request` for proof-of-possession. Safe to call repeatedly; each call mints a fresh - * signature window over the same token. */ - sign(request: AipRequestDescriptor): AipHeaders; -} - -/** Decode a JWT payload without verifying (display-only; the merchant + API re-verify the token). */ -function decodeJwtPayload(jwt: string): Record | null { - const parts = jwt.split('.'); - if (parts.length < 2) return null; - try { - const json = Buffer.from(parts[1]!, 'base64url').toString('utf8'); - const parsed = JSON.parse(json); - return typeof parsed === 'object' && parsed !== null ? (parsed as Record) : null; - } catch { - return null; - } -} - -/** - * Mint an AIT bound to pay's agent key and return a presenter that re-signs per request attempt. - */ -export async function createAipPresenter(opts: AipPresenterOptions): Promise { - const { privateKey, publicJwk } = await getOrCreateAgentKey(opts.passphrase); - const trustLevel = opts.trustLevel ?? inferTrustLevel(); - - const { token, expiresIn } = await mintAit({ - operatorToken: opts.operatorToken, - cnfJwk: publicJwk, - provider: opts.provider ?? 'agentscore-pay', - trustLevel, - ...(opts.intent !== undefined && { intent: opts.intent }), - ...(opts.actions !== undefined && { actions: opts.actions }), - ...(opts.auth !== undefined && { auth: opts.auth }), - ...(opts.baseUrl !== undefined && { baseUrl: opts.baseUrl }), - ...(opts.fetch !== undefined && { fetch: opts.fetch }), - }); - - const claims = decodeJwtPayload(token); - - return { - expiresIn, - trustLevel, - issuer: typeof claims?.iss === 'string' ? claims.iss : undefined, - subject: typeof claims?.sub === 'string' ? claims.sub : undefined, - sign(request: AipRequestDescriptor): AipHeaders { - const signed = signAitRequest({ - method: request.method, - authority: request.authority, - path: request.path, - agentIdentity: token, - privateKey, - publicJwk, - }); - return { - 'Agent-Identity': signed.agentIdentity, - 'Signature-Input': signed.signatureInput, - Signature: signed.signature, - }; - }, - }; -} diff --git a/src/commands/pay.ts b/src/commands/pay.ts index 726c3f5..e62dac2 100644 --- a/src/commands/pay.ts +++ b/src/commands/pay.ts @@ -19,21 +19,11 @@ import { withRetries } from '../retry'; import { selectRail } from '../selection'; import { assertCredentialTarget, createSecureFetch } from '../url-security'; import { createMppAccount, createX402Signer, loadWallet, type Wallet } from '../wallets'; -import type { AipRequestDescriptor } from '../aip/presenter'; import type { ClientEvmSigner } from '@x402/evm'; /** Which identity to present to the merchant. */ type IdentityMode = 'auto' | 'operator' | 'wallet'; -/** Parse a URL into the RFC 9421 covered components an AIT signature binds. Exported for tests. */ -export function requestDescriptor(url: string, method: string): AipRequestDescriptor { - const u = new URL(url); - // RFC 9421 @path is the absolute path ONLY, the query string is the separate @query component, - // which AIP's minimum covered set omits. The verifier reconstructs @path as pathname (query - // stripped), so signing pathname+search would make PoP fail for every query-bearing URL. - return { method: method.toUpperCase(), authority: u.host, path: u.pathname }; -} - export interface PayInput { chain?: Chain; network?: Network; diff --git a/src/passport/attach.ts b/src/passport/attach.ts index 1aa8210..8f7f423 100644 --- a/src/passport/attach.ts +++ b/src/passport/attach.ts @@ -42,7 +42,7 @@ export interface AttachInput { * and NOT credential-safe (non-https, except loopback http for dev), attach * refuses, returns `kind: 'insecure_target'` and never surfaces the * operator_token. Omit when resolving the passport for a use that does NOT put - * the bearer token on the wire (e.g. minting an @authority-bound AIP token). + * the bearer token on the wire. */ targetUrl?: string; /** Override "now" for testing. */ diff --git a/src/paths.ts b/src/paths.ts index 5adfb7c..0adc7a7 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -38,13 +38,6 @@ export function ledgerPath(): string { return join(baseDir(), 'history.jsonl'); } -/** Encrypted Ed25519 agent key used as the AIP AIT `cnf` (proof-of-possession) key. Separate - * from payment wallets (chain-typed secp256k1/ed25519 keys); this is one Ed25519 key for AIT - * signing, independent of any chain. */ -export function agentKeyPath(): string { - return join(baseDir(), 'aip-agent-key.json'); -} - export function limitsPath(): string { return join(baseDir(), 'limits.json'); } diff --git a/tests/aip_agent_key.test.ts b/tests/aip_agent_key.test.ts deleted file mode 100644 index 349b24f..0000000 --- a/tests/aip_agent_key.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Agent-key store: generate → persist (encrypted) → reload reproduces the same Ed25519 key, and - * the loaded key signs identically to the freshly-generated one. - */ -import { sign as nodeSign } from 'crypto'; -import { mkdir, mkdtemp, rm } from 'fs/promises'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { agentKeyExists, getOrCreateAgentKey } from '../src/aip/agent-key'; -import { jwkThumbprint } from '../src/aip/http-signature'; - -let home: string; -const PASS = 'test-passphrase-123'; - -beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), 'pay-aip-')); - process.env.AGENTSCORE_PAY_HOME = home; -}); -afterEach(async () => { - delete process.env.AGENTSCORE_PAY_HOME; - await rm(home, { recursive: true, force: true }); -}); - -describe('agent-key', () => { - it('creates on first use and reloads the same key', async () => { - expect(await agentKeyExists()).toBe(false); - const a = await getOrCreateAgentKey(PASS); - expect(await agentKeyExists()).toBe(true); - expect(a.publicJwk.kty).toBe('OKP'); - expect(a.publicJwk.crv).toBe('Ed25519'); - expect(a.publicJwk.x).toMatch(/^[A-Za-z0-9_-]+$/); // base64url - - const b = await getOrCreateAgentKey(PASS); - expect(b.publicJwk.x).toBe(a.publicJwk.x); - expect(jwkThumbprint(b.publicJwk)).toBe(jwkThumbprint(a.publicJwk)); - - // The reloaded private key signs identically to the original. - const msg = Buffer.from('proof-of-possession base'); - const s1 = nodeSign(null, msg, a.privateKey); - const s2 = nodeSign(null, msg, b.privateKey); - expect(s2.equals(s1)).toBe(true); - }); - - it('surfaces an actionable wrong_passphrase error (not a raw AES-GCM failure) on a passphrase mismatch', async () => { - await getOrCreateAgentKey(PASS); - await expect(getOrCreateAgentKey('wrong-passphrase-xyz')).rejects.toMatchObject({ - code: 'wrong_passphrase', - message: expect.stringContaining('aip-agent-key.json'), - nextSteps: { - action: 'retry_or_regenerate_agent_key', - suggestion: expect.stringContaining('delete'), - }, - }); - // The mismatch must NOT regenerate the key — the original passphrase still unlocks it. - await expect(getOrCreateAgentKey(PASS)).resolves.toBeDefined(); - }); - - it('agentKeyExists propagates non-ENOENT errors instead of reporting "missing"', async () => { - // A directory at the key path makes readFile fail with EISDIR — a transient/abnormal - // error must surface, never silently regenerate (and overwrite) the key. - await mkdir(join(home, 'aip-agent-key.json')); - await expect(agentKeyExists()).rejects.toMatchObject({ code: 'EISDIR' }); - await expect(getOrCreateAgentKey(PASS)).rejects.toMatchObject({ code: 'EISDIR' }); - }); - - it('a lost create race falls back to the persisted key instead of overwriting it', async () => { - // Both racers scrypt before either writes; the exclusive `wx` create makes the loser - // read the winner's key, so both resolve to the SAME persisted key. - const [a, b] = await Promise.all([getOrCreateAgentKey(PASS), getOrCreateAgentKey(PASS)]); - expect(b.publicJwk.x).toBe(a.publicJwk.x); - const reloaded = await getOrCreateAgentKey(PASS); - expect(reloaded.publicJwk.x).toBe(a.publicJwk.x); - }); -}); diff --git a/tests/aip_http_signature.test.ts b/tests/aip_http_signature.test.ts deleted file mode 100644 index 496b9df..0000000 --- a/tests/aip_http_signature.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Conformance test for pay's AIP RFC 9421 signer. Pins the wire format byte-for-byte against the - * spec the AgentScore commerce verifier (node-commerce/src/aip/http-signature.ts) enforces, so any - * drift fails CI. pay does NOT depend on the commerce SDK; instead we (1) reconstruct the exact - * signature base the verifier builds and confirm pay's signature verifies over it with node:crypto, - * and (2) assert the exact header serialization against fixtures. - */ -import { createPrivateKey, createPublicKey, createHash, generateKeyPairSync, verify as nodeVerify } from 'crypto'; -import { describe, expect, it } from 'vitest'; -import { jwkThumbprint, signAitRequest, type Ed25519PublicJwk } from '../src/aip/http-signature'; - -function makeKey(): { privateKey: ReturnType; publicJwk: Ed25519PublicJwk } { - const { privateKey } = generateKeyPairSync('ed25519'); - const j = privateKey.export({ format: 'jwk' }) as { x: string }; - return { privateKey, publicJwk: { kty: 'OKP', crv: 'Ed25519', x: j.x } }; -} - -// Independent reimplementation of the verifier's signature-base construction (from -// node-commerce/src/aip/http-signature.ts), used only to validate pay's output. If pay's signer -// and this diverge, the Ed25519 verify below fails. -function expectedBase(params: { - method: string; authority: string; path: string; agentIdentity: string; - created: number; keyid: string; expires?: number; -}): string { - const comps = ['@method', '@authority', '@path', 'agent-identity']; - const norm = (a: string) => { - const lower = a.trim().toLowerCase(); - const c = lower.lastIndexOf(':'); - if (c === -1) return lower; - if (lower.includes(']') && c < lower.indexOf(']')) return lower; // IPv6 literal, no port - const port = lower.slice(c + 1); - return port === '80' || port === '443' ? lower.slice(0, c) : lower; - }; - const val: Record = { - '@method': params.method.toUpperCase(), - '@authority': norm(params.authority), - '@path': params.path, - 'agent-identity': params.agentIdentity.trim(), - }; - const lines = comps.map((c) => `"${c}": ${val[c]}`); - const list = `(${comps.map((c) => `"${c}"`).join(' ')})`; - const expiresVal = params.expires ?? params.created + 60; // signer defaults expires = created + 60 - const paramStr = `;created=${params.created};expires=${expiresVal};keyid="${params.keyid}";tag="agent-identity"`; - lines.push(`"@signature-params": ${list}${paramStr}`); - return lines.join('\n'); -} - -describe('signAitRequest — RFC 9421 conformance', () => { - it('thumbprint matches the RFC 7638 canonical form', () => { - const { publicJwk } = makeKey(); - const expected = createHash('sha256') - .update(`{"crv":"Ed25519","kty":"OKP","x":"${publicJwk.x}"}`) - .digest('base64url'); - expect(jwkThumbprint(publicJwk)).toBe(expected); - }); - - it('emits the exact canonical bytes for the cross-repo vector (pay signer ↔ API verifier)', () => { - // Fixed Ed25519 test key shared with core/api's conformance test. Ed25519 is deterministic, so - // these bytes are stable. core/api/tests/aip-http-signature-conformance.test.ts pins that the API - // verifier ACCEPTS exactly this output — together they cross-pin pay's signer to the authoritative - // API verifier across repos; drift on either side breaks one of the two tests. - const privateKey = createPrivateKey({ - key: { kty: 'OKP', crv: 'Ed25519', x: '5vqAF8eRpE9bBrvNDfMcl4s1YKKEj_IjkC1Sb9RX7zQ', d: 'uZdreBtmZKhj5plteN2V8We6uI8o4hkNbJX_hbcCJUk' }, - format: 'jwk', - }); - const publicJwk: Ed25519PublicJwk = { kty: 'OKP', crv: 'Ed25519', x: '5vqAF8eRpE9bBrvNDfMcl4s1YKKEj_IjkC1Sb9RX7zQ' }; - const { signatureInput, signature } = signAitRequest({ - method: 'POST', authority: 'merchant.example.com', path: '/checkout', agentIdentity: 'AIT.fixture.token', - privateKey, publicJwk, created: 1_715_400_000, expires: 1_715_400_060, - }); - expect(jwkThumbprint(publicJwk)).toBe('mkD85lyXqPsAQ7obXi4KLYtotBqEZP7j0U23VKZc8EI'); - expect(signatureInput).toBe( - 'ait=("@method" "@authority" "@path" "agent-identity");created=1715400000;expires=1715400060;keyid="mkD85lyXqPsAQ7obXi4KLYtotBqEZP7j0U23VKZc8EI";tag="agent-identity"', - ); - expect(signature).toBe( - 'ait=:1CE7njbRqJUuxYtNcTFjTax1mg+52Rqc633BwdWqBraCur+PUmX8v0VN5y2QiiTl+22rD4f4RkSSINSrVI5pBQ==:', - ); - }); - - it('produces a signature that verifies over the verifier-spec signature base', () => { - const { privateKey, publicJwk } = makeKey(); - const created = 1_715_400_000; - const req = { method: 'post', authority: 'Wine.Example:443', path: '/purchase', agentIdentity: ' a.b.c ' }; - const { signatureInput, signature } = signAitRequest({ ...req, privateKey, publicJwk, created }); - - // Header shape. - const keyid = jwkThumbprint(publicJwk); - expect(signatureInput).toBe( - `ait=("@method" "@authority" "@path" "agent-identity");created=${created};expires=${created + 60};keyid="${keyid}";tag="agent-identity"`, - ); - expect(signature).toMatch(/^ait=:[A-Za-z0-9+/]+=*:$/); // standard base64 byte-sequence - - // The signature must verify (Ed25519) over the INDEPENDENTLY rebuilt base — proves byte match. - const base = expectedBase({ ...req, created, keyid }); - const sigB64 = signature.slice('ait=:'.length, -1); - const pub = createPublicKey({ key: { ...publicJwk }, format: 'jwk' }); - const ok = nodeVerify(null, Buffer.from(base, 'utf8'), pub, Buffer.from(sigB64, 'base64')); - expect(ok).toBe(true); - }); - - it('normalizes @authority (lowercase, drops :443) and uppercases @method', () => { - const { privateKey, publicJwk } = makeKey(); - const created = 1_715_400_001; - const { signature } = signAitRequest({ - method: 'get', authority: 'API.Example.COM:443', path: '/x', agentIdentity: 'tok', - privateKey, publicJwk, created, - }); - const keyid = jwkThumbprint(publicJwk); - const base = expectedBase({ method: 'get', authority: 'API.Example.COM:443', path: '/x', agentIdentity: 'tok', created, keyid }); - // base must contain the normalized authority + uppercased method - expect(base).toContain('"@authority": api.example.com'); - expect(base).toContain('"@method": GET'); - const pub = createPublicKey({ key: { ...publicJwk }, format: 'jwk' }); - const ok = nodeVerify(null, Buffer.from(base, 'utf8'), pub, Buffer.from(signature.slice('ait=:'.length, -1), 'base64')); - expect(ok).toBe(true); - }); - - it('serializes expires in canonical order (created, expires, keyid, tag) and verifies', () => { - const { privateKey, publicJwk } = makeKey(); - const created = 1_715_400_003; - const expires = created + 300; - const req = { method: 'POST', authority: 'wine.example', path: '/purchase', agentIdentity: 'a.b.c' }; - const { signatureInput, signature } = signAitRequest({ ...req, privateKey, publicJwk, created, expires }); - const keyid = jwkThumbprint(publicJwk); - expect(signatureInput).toBe( - `ait=("@method" "@authority" "@path" "agent-identity");created=${created};expires=${expires};keyid="${keyid}";tag="agent-identity"`, - ); - const base = expectedBase({ ...req, created, expires, keyid }); - const pub = createPublicKey({ key: { ...publicJwk }, format: 'jwk' }); - const ok = nodeVerify(null, Buffer.from(base, 'utf8'), pub, Buffer.from(signature.slice('ait=:'.length, -1), 'base64')); - expect(ok).toBe(true); - }); - - it('normalizes an IPv6 authority (drops :443, keeps the bracketed literal) and verifies', () => { - const { privateKey, publicJwk } = makeKey(); - const created = 1_715_400_004; - const req = { method: 'POST', authority: '[2001:DB8::1]:443', path: '/x', agentIdentity: 'tok' }; - const { signature } = signAitRequest({ ...req, privateKey, publicJwk, created }); - const keyid = jwkThumbprint(publicJwk); - const base = expectedBase({ ...req, created, keyid }); - expect(base).toContain('"@authority": [2001:db8::1]'); // lowercased, :443 dropped, brackets kept - const pub = createPublicKey({ key: { ...publicJwk }, format: 'jwk' }); - const ok = nodeVerify(null, Buffer.from(base, 'utf8'), pub, Buffer.from(signature.slice('ait=:'.length, -1), 'base64')); - expect(ok).toBe(true); - }); -}); diff --git a/tests/aip_mint.test.ts b/tests/aip_mint.test.ts deleted file mode 100644 index f6553c7..0000000 --- a/tests/aip_mint.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * Mint client + present flow: mocked issuer fetch → mint → RFC 9421 headers; trust_level - * inference; error mapping. - */ -import { mkdtemp, rm } from 'fs/promises'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { AgentScoreError } from '@agent-score/sdk'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { inferTrustLevel, mintAit } from '../src/aip/mint'; -import { presentAit } from '../src/aip/present'; - -interface MintBody { - operator_token?: string; - cnf_jwk?: { kty?: string; crv?: string; x?: string }; - trust_level?: string; - agent?: { provider?: string }; - auth?: { amr?: string[]; time?: number }; - intent?: { description?: string; actions?: string[] }; -} - -let home: string; -const PASS = 'test-passphrase-123'; -const OPERATOR = `opc_${'a'.repeat(64)}`; - -beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), 'pay-aip-mint-')); - process.env.AGENTSCORE_PAY_HOME = home; -}); -afterEach(async () => { - delete process.env.AGENTSCORE_PAY_HOME; - await rm(home, { recursive: true, force: true }); -}); - -const okFetch = (captured?: { body?: unknown; headers?: Record }) => - vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { - if (captured) { - captured.body = init?.body ? JSON.parse(init.body as string) : undefined; - captured.headers = (init?.headers as Record) ?? {}; - } - return { - ok: true, - status: 201, - text: async () => JSON.stringify({ token: 'header.payload.sig', token_type: 'AIT', expires_in: 300 }), - } as unknown as Response; - }); - -describe('mintAit', () => { - it('posts cnf + operator_token + trust_level with the pay client id, returns the token', async () => { - const cap: { body?: MintBody; headers?: Record } = {}; - const r = await mintAit({ - operatorToken: OPERATOR, - cnfJwk: { kty: 'OKP', crv: 'Ed25519', x: 'abc' }, - provider: 'anthropic', - trustLevel: 'human_present', - fetch: okFetch(cap), - }); - expect(r.token).toBe('header.payload.sig'); - expect(r.expiresIn).toBe(300); - expect(cap.headers?.['X-Client-Id']).toBe('agentscore_pay_pubclient_v1'); - expect(cap.body?.operator_token).toBe(OPERATOR); - expect(cap.body?.cnf_jwk).toEqual({ kty: 'OKP', crv: 'Ed25519', x: 'abc' }); - expect(cap.body?.trust_level).toBe('human_present'); - expect(cap.body?.agent?.provider).toBe('anthropic'); - }); - - it('sends human_confirmed with auth.amr + intent (description + actions)', async () => { - const cap: { body?: MintBody } = {}; - await mintAit({ - operatorToken: OPERATOR, - cnfJwk: { kty: 'OKP', crv: 'Ed25519', x: 'abc' }, - provider: 'anthropic', - trustLevel: 'human_confirmed', - intent: 'Buy a 2021 Pinot Noir', - actions: ['purchase'], - auth: { amr: ['user'] }, - fetch: okFetch(cap), - }); - expect(cap.body?.trust_level).toBe('human_confirmed'); - expect(cap.body?.auth).toEqual({ amr: ['user'] }); - expect(cap.body?.intent).toEqual({ description: 'Buy a 2021 Pinot Noir', actions: ['purchase'] }); - }); - - it('sends intent.actions without a description (the action-only, non-confirmed case)', async () => { - const cap: { body?: MintBody } = {}; - await mintAit({ - operatorToken: OPERATOR, - cnfJwk: { kty: 'OKP', crv: 'Ed25519', x: 'abc' }, - provider: 'p', - trustLevel: 'human_present', - actions: ['purchase'], - fetch: okFetch(cap), - }); - expect(cap.body?.intent).toEqual({ actions: ['purchase'] }); - expect(cap.body?.auth).toBeUndefined(); - }); - - it('omits intent + auth entirely when none are set', async () => { - const cap: { body?: MintBody } = {}; - await mintAit({ - operatorToken: OPERATOR, - cnfJwk: { kty: 'OKP', crv: 'Ed25519', x: 'abc' }, - provider: 'p', - trustLevel: 'autonomous', - fetch: okFetch(cap), - }); - expect(cap.body?.intent).toBeUndefined(); - expect(cap.body?.auth).toBeUndefined(); - }); - - it('maps an error response to AgentScoreError with the issuer code', async () => { - const errFetch = vi.fn(async () => ({ - ok: false, - status: 403, - text: async () => JSON.stringify({ error: { code: 'unregistered_client_id', message: 'nope' } }), - }) as unknown as Response); - await expect( - mintAit({ operatorToken: OPERATOR, cnfJwk: { kty: 'OKP', crv: 'Ed25519', x: 'a' }, provider: 'p', trustLevel: 'autonomous', fetch: errFetch }), - ).rejects.toMatchObject({ code: 'unregistered_client_id' }); - }); - - it('falls back to http_error when an error body is not JSON', async () => { - const errFetch = vi.fn(async () => ({ ok: false, status: 502, text: async () => 'bad gateway' }) as unknown as Response); - await expect( - mintAit({ operatorToken: OPERATOR, cnfJwk: { kty: 'OKP', crv: 'Ed25519', x: 'a' }, provider: 'p', trustLevel: 'autonomous', fetch: errFetch }), - ).rejects.toMatchObject({ code: 'http_error', status: 502 }); - }); - - it('rejects a non-JSON success body as invalid_response', async () => { - const f = vi.fn(async () => ({ ok: true, status: 201, text: async () => 'not json' }) as unknown as Response); - await expect( - mintAit({ operatorToken: OPERATOR, cnfJwk: { kty: 'OKP', crv: 'Ed25519', x: 'a' }, provider: 'p', trustLevel: 'autonomous', fetch: f }), - ).rejects.toMatchObject({ code: 'invalid_response' }); - }); - - it('rejects a success body missing the token as invalid_response', async () => { - const f = vi.fn(async () => ({ ok: true, status: 201, text: async () => JSON.stringify({ token_type: 'AIT', expires_in: 300 }) }) as unknown as Response); - await expect( - mintAit({ operatorToken: OPERATOR, cnfJwk: { kty: 'OKP', crv: 'Ed25519', x: 'a' }, provider: 'p', trustLevel: 'autonomous', fetch: f }), - ).rejects.toMatchObject({ code: 'invalid_response' }); - }); - - it('rejects an empty-string token as invalid_response', async () => { - const f = vi.fn(async () => ({ ok: true, status: 201, text: async () => JSON.stringify({ token: '', expires_in: 300 }) }) as unknown as Response); - await expect( - mintAit({ operatorToken: OPERATOR, cnfJwk: { kty: 'OKP', crv: 'Ed25519', x: 'a' }, provider: 'p', trustLevel: 'autonomous', fetch: f }), - ).rejects.toMatchObject({ code: 'invalid_response' }); - }); - - it('REFUSES a redirect off the issuer origin — the operator_token body is never re-sent', async () => { - // undici re-issues request bodies on cross-origin 307/308; the mint body carries the bearer - // operator_token, so the redirect must abort instead of forwarding it to the foreign origin. - const f = vi.fn(async () => - new Response(null, { status: 307, headers: { location: 'https://attacker.example/v1/agent-identity/token' } }), - ) as unknown as typeof globalThis.fetch; - await expect( - mintAit({ operatorToken: OPERATOR, cnfJwk: { kty: 'OKP', crv: 'Ed25519', x: 'a' }, provider: 'p', trustLevel: 'autonomous', fetch: f }), - ).rejects.toMatchObject({ code: 'credential_redirect_blocked' }); - expect(f).toHaveBeenCalledTimes(1); // the foreign origin was never fetched - }); - - it('falls back to an absolute default URL when baseUrl is empty (no relative request)', async () => { - const cap: { url?: string } = {}; - const f = vi.fn(async (url: string | URL | Request) => { - cap.url = String(url); - return { ok: true, status: 201, text: async () => JSON.stringify({ token: 'a.b.c', expires_in: 300 }) } as unknown as Response; - }); - await mintAit({ operatorToken: OPERATOR, cnfJwk: { kty: 'OKP', crv: 'Ed25519', x: 'a' }, provider: 'p', trustLevel: 'autonomous', baseUrl: '', fetch: f }); - expect(cap.url?.startsWith('http')).toBe(true); - expect(cap.url).toContain('/v1/agent-identity/token'); - }); -}); - -describe('inferTrustLevel', () => { - const origIn = process.stdin.isTTY; - const origOut = process.stdout.isTTY; - afterEach(() => { - process.stdin.isTTY = origIn; - process.stdout.isTTY = origOut; - }); - - it('is human_present when both stdin and stdout are a TTY', () => { - process.stdin.isTTY = true; - process.stdout.isTTY = true; - expect(inferTrustLevel()).toBe('human_present'); - }); - - it('is autonomous when not a TTY (piped / CI / headless)', () => { - process.stdin.isTTY = false; - process.stdout.isTTY = false; - expect(inferTrustLevel()).toBe('autonomous'); - }); -}); - -describe('presentAit', () => { - it('mints + signs, returning the three presentation headers', async () => { - const cap: { body?: MintBody } = {}; - const presented = await presentAit({ - operatorToken: OPERATOR, - passphrase: PASS, - request: { method: 'POST', authority: 'wine.example', path: '/purchase' }, - trustLevel: 'autonomous', - fetch: okFetch(cap), - }); - expect(presented.headers['Agent-Identity']).toBe('header.payload.sig'); - expect(presented.headers['Signature-Input']).toMatch(/^ait=\("@method" "@authority" "@path" "agent-identity"\);created=\d+;expires=\d+;keyid="[^"]+";tag="agent-identity"$/); - expect(presented.headers.Signature).toMatch(/^ait=:[A-Za-z0-9+/]+=*:$/); - expect(presented.trustLevel).toBe('autonomous'); - // The cnf sent to the issuer is the same key whose thumbprint is the signature keyid. - expect(cap.body?.cnf_jwk?.kty).toBe('OKP'); - expect(AgentScoreError).toBeDefined(); - }); -}); diff --git a/tests/aip_presenter.test.ts b/tests/aip_presenter.test.ts deleted file mode 100644 index f6b1d45..0000000 --- a/tests/aip_presenter.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * AIP presenter: mints the Agent Identity Token once, re-signs per request attempt, decodes - * iss/sub for display, and carries an optional intent into the mint body. - */ -import { mkdtemp, rm } from 'fs/promises'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createAipPresenter } from '../src/aip/presenter'; - -let home: string; -const OPERATOR = `opc_${'a'.repeat(64)}`; -const PASS = 'test-passphrase-123'; - -beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), 'pay-aip-presenter-')); - process.env.AGENTSCORE_PAY_HOME = home; - process.env.AGENTSCORE_PAY_PASSPHRASE = PASS; -}); -afterEach(async () => { - delete process.env.AGENTSCORE_PAY_HOME; - delete process.env.AGENTSCORE_PAY_PASSPHRASE; - await rm(home, { recursive: true, force: true }); -}); - -const b64url = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString('base64url'); -const jwt = (payload: Record): string => - `${b64url({ alg: 'EdDSA', typ: 'JWT' })}.${b64url(payload)}.sig`; - -const mintFetch = (token: string, onBody?: (body: Record) => void) => - vi.fn(async (_url: string, opts?: { body?: string }) => { - if (onBody && opts?.body) onBody(JSON.parse(opts.body)); - return { - ok: true, - status: 201, - text: async () => JSON.stringify({ token, token_type: 'AIT', expires_in: 300 }), - } as unknown as Response; - }); - -describe('createAipPresenter', () => { - it('mints once, decodes iss/sub, and signs the request with the three PoP headers', async () => { - const token = jwt({ iss: 'https://www.agentscore.com', sub: 'as_op_test0001' }); - const fetchMock = mintFetch(token); - const presenter = await createAipPresenter({ operatorToken: OPERATOR, passphrase: PASS, fetch: fetchMock }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(presenter.issuer).toBe('https://www.agentscore.com'); - expect(presenter.subject).toBe('as_op_test0001'); - expect(presenter.expiresIn).toBe(300); - expect(['autonomous', 'human_present']).toContain(presenter.trustLevel); - - const h = presenter.sign({ method: 'POST', authority: 'wine.example', path: '/purchase' }); - expect(h['Agent-Identity']).toBe(token); - expect(h['Signature-Input']).toContain('tag="agent-identity"'); - expect(h.Signature).toMatch(/^ait=:.+:$/); - }); - - it('re-signs per call WITHOUT re-minting (different request → different signature)', async () => { - const fetchMock = mintFetch(jwt({ iss: 'x', sub: 'y' })); - const presenter = await createAipPresenter({ operatorToken: OPERATOR, passphrase: PASS, fetch: fetchMock }); - - const a = presenter.sign({ method: 'POST', authority: 'm.example', path: '/a' }); - const b = presenter.sign({ method: 'POST', authority: 'm.example', path: '/b' }); - - expect(fetchMock).toHaveBeenCalledTimes(1); // minted once - expect(a.Signature).not.toBe(b.Signature); // path is a covered component → distinct signature - }); - - it('carries --intent into the mint body as intent.description', async () => { - let captured: Record | undefined; - const fetchMock = mintFetch(jwt({ iss: 'x', sub: 'y' }), (body) => (captured = body)); - await createAipPresenter({ operatorToken: OPERATOR, passphrase: PASS, intent: 'Buy a 2021 Pinot Noir', fetch: fetchMock }); - - expect(captured?.intent).toEqual({ description: 'Buy a 2021 Pinot Noir' }); - expect(captured?.trust_level).toBeDefined(); - }); - - it('omits intent when none is given', async () => { - let captured: Record | undefined; - const fetchMock = mintFetch(jwt({ iss: 'x', sub: 'y' }), (body) => (captured = body)); - await createAipPresenter({ operatorToken: OPERATOR, passphrase: PASS, fetch: fetchMock }); - - expect(captured).toBeDefined(); - expect('intent' in (captured as Record)).toBe(false); - }); -}); diff --git a/tests/aip_request_descriptor.test.ts b/tests/aip_request_descriptor.test.ts deleted file mode 100644 index 760ac0d..0000000 --- a/tests/aip_request_descriptor.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Regression: pay must sign the RFC 9421 @path as the absolute path ONLY. Signing pathname+search - * made proof-of-possession fail for every query-bearing URL, because the verifier (node-commerce - * edge + core/api) reconstructs @path as pathname — the query is the separate @query component that - * AIP's minimum covered set omits. - */ -import { describe, expect, it } from 'vitest'; -import { requestDescriptor } from '../src/commands/pay'; - -describe('requestDescriptor — RFC 9421 @path', () => { - it('excludes the query string from @path (uppercases method)', () => { - expect(requestDescriptor('https://m.example/checkout?order=42&x=1', 'post')).toEqual({ - method: 'POST', - authority: 'm.example', - path: '/checkout', - }); - }); - - it('handles a query-less path unchanged', () => { - expect(requestDescriptor('https://m.example/wines/pinot', 'GET').path).toBe('/wines/pinot'); - }); - - it('keeps a non-default port in the authority but still drops the query', () => { - expect(requestDescriptor('https://m.example:8443/x?q=1', 'GET')).toMatchObject({ - authority: 'm.example:8443', - path: '/x', - }); - }); -});