From 827de27adfd5eb65b336515010e2b04efcca9eb0 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 7 Oct 2026 20:30:26 -0700 Subject: [PATCH] Remove the unused identity-token gate The token-based identity path is retired: the gate option and its offline verifier, the framework adapter gates, the agent-card extension, and the discovery and agent-memory advertising for it are removed, along with their tests. Takes @agent-score/sdk ^2.9.0. Minor bump: no merchant uses it. --- bun.lock | 4 +- package.json | 4 +- src/aip/gate.ts | 349 -------------------- src/aip/http-signature.ts | 505 ----------------------------- src/aip/jwks.ts | 290 ----------------- src/aip/request.ts | 113 ------- src/aip/types.ts | 185 ----------- src/aip/verify.ts | 257 --------------- src/challenge/agent_memory.ts | 7 +- src/checkout.ts | 332 ++----------------- src/core.ts | 88 +---- src/discovery/llms_txt.ts | 14 +- src/discovery/openapi.ts | 25 +- src/discovery/skill_md.ts | 18 +- src/discovery/well_known_mpp.ts | 3 - src/identity/a2a.ts | 33 -- src/identity/express.ts | 71 +--- src/identity/fastify.ts | 76 +---- src/identity/hono.ts | 72 +--- src/identity/nextjs.ts | 15 +- src/identity/web.ts | 68 +--- src/index.ts | 59 ---- src/payment/payment_header.ts | 6 +- tests/_response.test.ts | 2 +- tests/agent_memory_aip.test.ts | 37 --- tests/aip_adapters.test.ts | 284 ---------------- tests/aip_gate.test.ts | 206 ------------ tests/aip_hono_gate.test.ts | 176 ---------- tests/aip_http_signature.test.ts | 375 --------------------- tests/aip_jwks.test.ts | 437 ------------------------- tests/aip_presentation.test.ts | 94 ------ tests/aip_request.test.ts | 85 ----- tests/aip_types.test.ts | 126 ------- tests/aip_verify.test.ts | 309 ------------------ tests/checkout_aip.test.ts | 351 -------------------- tests/checkout_aip_forward.test.ts | 176 ---------- tests/core_signer_cache.test.ts | 4 +- tests/identity/a2a.test.ts | 30 -- tests/verification_session.test.ts | 9 +- 39 files changed, 71 insertions(+), 5224 deletions(-) delete mode 100644 src/aip/gate.ts delete mode 100644 src/aip/http-signature.ts delete mode 100644 src/aip/jwks.ts delete mode 100644 src/aip/request.ts delete mode 100644 src/aip/types.ts delete mode 100644 src/aip/verify.ts delete mode 100644 tests/agent_memory_aip.test.ts delete mode 100644 tests/aip_adapters.test.ts delete mode 100644 tests/aip_gate.test.ts delete mode 100644 tests/aip_hono_gate.test.ts delete mode 100644 tests/aip_http_signature.test.ts delete mode 100644 tests/aip_jwks.test.ts delete mode 100644 tests/aip_presentation.test.ts delete mode 100644 tests/aip_request.test.ts delete mode 100644 tests/aip_types.test.ts delete mode 100644 tests/aip_verify.test.ts delete mode 100644 tests/checkout_aip.test.ts delete mode 100644 tests/checkout_aip_forward.test.ts diff --git a/bun.lock b/bun.lock index 2fd24de..3892ef2 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@agent-score/commerce", "dependencies": { - "@agent-score/sdk": "^2.8.1", + "@agent-score/sdk": "^2.9.0", }, "devDependencies": { "@a2a-js/sdk": "^1.3.0", @@ -72,7 +72,7 @@ "@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/package.json b/package.json index d58471e..fee04a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/commerce", - "version": "3.0.1", + "version": "3.1.0", "description": "Agentic commerce SDK: identity middleware (Hono, Express, Fastify, Next.js, Web Fetch) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agentic commerce.", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -147,7 +147,7 @@ "bun": ">=1.3.0 <2" }, "dependencies": { - "@agent-score/sdk": "^2.8.1" + "@agent-score/sdk": "^2.9.0" }, "overrides": { "proxy-addr": "^2.0.8", diff --git a/src/aip/gate.ts b/src/aip/gate.ts deleted file mode 100644 index ef2a39d..0000000 --- a/src/aip/gate.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Framework-agnostic AIP gate orchestration. - * - * `verifyAitRequest` is the one call a framework adapter makes: hand it a standard `Request` - * plus a {@link JwksCache}, and it returns the verified AIT claims or a typed failure. The - * helpers here also map that failure onto the AIP wire contract: HTTP status + error code + - * an RFC 9457 problem-details body: so every adapter renders denials identically. - * - * This layer does identity *verification* only (is this a real, key-bound AIT from a trusted - * IdP?). Policy enrichment (sanctions, jurisdiction, cross-merchant graph) happens when the - * merchant additionally feeds the verified claims to `/v1/assess`; that's the gate's choice, - * not something this module forces. - */ - -import { buildVerifyContextFromParts, buildVerifyContextFromRequest } from './request'; -import { verifyAit, type VerifiedAit, type VerifyAitFailure, type VerifyRequestContext } from './verify'; -import type { JwksCache } from './jwks'; -import type { TrustLevel } from './types'; - -export interface AipGateOptions { - jwks: JwksCache; - now?: number; - maxSkewSeconds?: number; - /** Expected `@authority` (public hostname) the RFC 9421 signature must cover. When set, the - * verifier binds the signature to this value instead of trusting the inbound `Host` header: - * pin it to your real public host (e.g. `'wine.example.com'`) when behind a proxy or - * multi-vhost listener that does not normalize `Host`, to prevent a captured AIT+signature - * from being replayed to a different virtual host on the same origin. Same semantics as - * Checkout's `AipGateConfig.authority`. */ - authority?: string; - /** Minimum `trust_level` (autonomous < human_present < human_confirmed) the AIT must assert: - * the spec's human-presence gate. Insufficient → 403 weak_auth with `required_trust_level`. - * Enforced by {@link evaluateAipRequest} / {@link evaluateAipParts}. Unset = any trust level. */ - requireTrustLevel?: TrustLevel; - /** Acceptable `auth.amr` methods (RFC 8176); the AIT must carry ≥1. Insufficient → 403 weak_auth - * with `required_amr`. Unset = not enforced. */ - requireAmr?: string[]; - /** Identity claims the endpoint needs: surfaced as `required_claims` on insufficient_claims - * denials so the agent can self-correct. Advisory only (enforce by feeding the verified claims - * to your own policy / `/v1/assess`; this gate does identity + trust_level/amr). */ - requiredClaims?: string[]; - /** Trusted issuer URLs surfaced as `trusted_issuers` on untrusted_issuer denials. */ - trustedIssuers?: string[]; -} - -export type AipGateResult = - | { ok: true; ait: VerifiedAit } - | { ok: false; failure: VerifyAitFailure }; - -/** Verify the AIP credential from a pre-built context. Shared by all adapter entry points. */ -const verifyFromContext = async (ctx: VerifyRequestContext, opts: AipGateOptions): Promise => { - const result = await verifyAit(ctx, { jwks: opts.jwks, now: opts.now, maxSkewSeconds: opts.maxSkewSeconds }); - return result.ok ? { ok: true, ait: result.ait } : { ok: false, failure: result.reason }; -}; - -/** Apply the gate's configured `authority` pin to a parts object: an explicit per-call - * `parts.authority` (e.g. Checkout's) wins, then the gate option, then the inbound `Host`. */ -const partsWithAuthority = ( - parts: { method: string; url: string; headers: Record; authority?: string }, - opts: AipGateOptions, -): typeof parts => { - const authority = parts.authority ?? opts.authority; - return authority !== undefined ? { ...parts, authority } : parts; -}; - -/** Verify the AIP credential on a standard Fetch `Request` (Hono / Web / Next.js). */ -export const verifyAitRequest = (req: Request, opts: AipGateOptions): Promise => - verifyFromContext(buildVerifyContextFromRequest(req, opts.authority), opts); - -/** Verify the AIP credential from Express/Fastify-style parts (Node header map + method + url). */ -export const verifyAitParts = ( - parts: { method: string; url: string; headers: Record; authority?: string }, - opts: AipGateOptions, -): Promise => verifyFromContext(buildVerifyContextFromParts(partsWithAuthority(parts, opts)), opts); - -/** Map an internal verify failure to the AIP wire error code (per spec error taxonomy). */ -export const aipErrorCode = (failure: VerifyAitFailure): string => { - switch (failure) { - case 'no_token': - case 'pop_signature_missing': - return 'agent_identity_required'; - case 'untrusted_issuer': - return 'untrusted_issuer'; - case 'expired_token': - return 'expired_token'; - case 'invalid_claims': - return 'insufficient_claims'; - case 'key_unavailable': - // The IdP's JWKS could not be fetched/resolved: our infra couldn't reach a trusted - // issuer, not a client-side auth failure. Distinct code so agents back off + retry - // rather than uselessly re-signing. - return 'idp_unavailable'; - case 'malformed_token': - case 'idp_signature_invalid': - case 'pop_signature_invalid': - return 'invalid_signature'; - default: - return 'invalid_signature'; - } -}; - -/** HTTP status for an AIP verify failure: 503 when our infra couldn't reach the IdP (retryable), - * 403 for trust/claims, 401 for auth-presence/signature. */ -export const aipErrorStatus = (failure: VerifyAitFailure): 401 | 403 | 503 => { - switch (failure) { - case 'key_unavailable': - return 503; - case 'untrusted_issuer': - case 'invalid_claims': - return 403; - default: - return 401; - } -}; - -/** Human-readable detail for the failure, for the problem-details body. */ -const aipErrorDetail = (failure: VerifyAitFailure): string => { - switch (failure) { - case 'no_token': - return 'No Agent-Identity token was presented.'; - case 'pop_signature_missing': - return 'The request is missing the RFC 9421 HTTP Message Signature that proves possession of the token-bound key.'; - case 'untrusted_issuer': - return "The token's issuer is not in this service's trusted-issuer list."; - case 'expired_token': - return 'The Agent Identity Token has expired.'; - case 'invalid_claims': - return 'The token is missing required claims for this endpoint.'; - case 'malformed_token': - return 'The Agent-Identity header could not be parsed as an Agent Identity Token.'; - case 'idp_signature_invalid': - return "The identity provider's signature on the token failed verification."; - case 'pop_signature_invalid': - return 'The request signature did not match the key bound to the token.'; - case 'key_unavailable': - return "The identity provider's signing key could not be resolved."; - default: - return 'Token verification failed.'; - } -}; - -/** RFC 9457 problem-details body for an AIP denial. Known fields are typed; `required_*` / - * `trusted_issuers` escalation extensions ride in the index signature. */ -export type AipErrorBody = { type: string; title: string; status: number; detail: string; [k: string]: unknown }; - -/** Merchant requirements attached to an AIP escalation body so the agent can self-correct. */ -export interface AipErrorRequirements { - trustedIssuers?: string[]; - requiredClaims?: string[]; - requiredTrustLevel?: TrustLevel; - requiredAmr?: string[]; -} - -/** - * Build an RFC 9457 problem-details body for an AIP verify failure. Adapters serialize this as - * `application/problem+json` with {@link aipErrorStatus}. Optionally carries the merchant's - * requirements: `trusted_issuers` on untrusted_issuer; `required_claims` / `required_trust_level` / - * `required_amr` on insufficient_claims: so the agent learns what would satisfy the gate. - */ -export const buildAipErrorBody = (failure: VerifyAitFailure, requirements?: AipErrorRequirements): AipErrorBody => { - const code = aipErrorCode(failure); - const body: AipErrorBody = { - type: `urn:aip:error:${code}`, - title: code.replace(/_/g, ' '), - status: aipErrorStatus(failure), - detail: aipErrorDetail(failure), - }; - if (requirements) { - if (code === 'untrusted_issuer' && requirements.trustedIssuers?.length) { - body.trusted_issuers = requirements.trustedIssuers; - } - if (code === 'insufficient_claims') { - if (requirements.requiredClaims?.length) body.required_claims = requirements.requiredClaims; - if (requirements.requiredTrustLevel !== undefined) body.required_trust_level = requirements.requiredTrustLevel; - if (requirements.requiredAmr?.length) body.required_amr = requirements.requiredAmr; - } - } - return body; -}; - -/** - * Map an AgentScore *policy-deny* code (a `/v1/assess` decision, NOT a verify failure) to its - * spec AIP error code + HTTP status. This is the policy-side counterpart to {@link aipErrorCode} - * (which maps the verify-FAILURE taxonomy). On the AIT-input path a denied `/v1/assess` decision - * surfaces as one of these AgentScore codes; the spec's fixed error set expresses each as: - * - `token_expired` → `expired_token` (401) - * - `invalid_credential` → `invalid_signature` (401) - * - `api_error` → `idp_unavailable` (503, transient: the claims couldn't be evaluated) - * - everything else (compliance: `wallet_not_trusted` + `sanctions_flagged` / `age_insufficient` - * / `jurisdiction_restricted` / `kyc_*`) → `insufficient_claims` (403): the AIT did not attest - * (or attested a failing value for) the required compliance claim. - */ -const aipPolicyDenyCode = (code: string): { code: string; status: 401 | 403 | 503 } => { - switch (code) { - case 'token_expired': - return { code: 'expired_token', status: 401 }; - case 'invalid_credential': - return { code: 'invalid_signature', status: 401 }; - case 'api_error': - return { code: 'idp_unavailable', status: 503 }; - default: - return { code: 'insufficient_claims', status: 403 }; - } -}; - -/** - * Wrap an AgentScore AIT-path denial body in the RFC 9457 + AIP-spec superset. - * - * Reuses {@link buildAipErrorBody}'s SHAPE convention (`type`/`title`/`status`/`detail` + - * escalation extensions) but for the *policy-deny* case: a verified AIT that `/v1/assess` then - * denied: which carries an AgentScore compliance/credential code, not a verify-failure reason. - * - * The result is a SUPERSET: the canonical `{ error, agent_instructions, ... }` body is spread in - * verbatim (so existing consumers keep parsing `error.code`), with the RFC 9457 envelope layered - * on top. `detail` names the precise AgentScore reason(s); `error.code` stays the AgentScore code. - * Escalation fields (`required_claims` / `required_trust_level` / `required_amr` on - * insufficient_claims, `trusted_issuers` on untrusted_issuer) ride along when known. - */ -export const buildAipPolicyDenyBody = ( - code: string, - reasons: string[] | undefined, - body: Record, - requirements?: AipErrorRequirements, -): Record => { - const spec = aipPolicyDenyCode(code); - const reasonList = reasons ?? []; - const detail = - spec.code === 'insufficient_claims' - ? reasonList.length > 0 - ? `The Agent Identity Token did not attest a passing value for the required compliance claim(s). AgentScore decision: ${code} (${reasonList.join(', ')}).` - : `The Agent Identity Token did not satisfy the merchant's compliance policy. AgentScore decision: ${code}.` - : `AgentScore decision: ${code}.`; - const superset: Record = { - type: `urn:aip:error:${spec.code}`, - title: spec.code.replace(/_/g, ' '), - status: spec.status, - detail, - }; - // Escalation extensions, scoped exactly as the spec mandates: `required_claims` / - // `required_trust_level` / `required_amr` on insufficient_claims. `trusted_issuers` belongs to - // untrusted_issuer (a VERIFY failure that never reaches the policy-deny path) so it is not - // emitted here (the edge-deny `buildAipErrorBody` owns that one). - if (requirements && spec.code === 'insufficient_claims') { - if (requirements.requiredClaims?.length) superset.required_claims = requirements.requiredClaims; - if (requirements.requiredTrustLevel !== undefined) superset.required_trust_level = requirements.requiredTrustLevel; - if (requirements.requiredAmr?.length) superset.required_amr = requirements.requiredAmr; - } - // Spread the canonical body LAST so `error` / `agent_instructions` / `reasons` win verbatim: - // the RFC 9457 fields are additive and never clobber the rich AgentScore scheme. The envelope - // fields themselves (`type` / `title` / `status` / `detail`) are RESERVED the other way: the - // canonical body never carries them legitimately, but a merchant `onBeforeSession` hook's - // `extra` rides through `denialReasonToBody` unfiltered: strip them so a smuggled `status` - // can't rewrite the problem+json envelope (or the HTTP status Checkout derives from it). - const { type: _type, title: _title, status: _status, detail: _detail, ...rest } = body; - return { ...superset, ...rest }; -}; - -/** Trust-level ordering: a token satisfies a requirement when its level ≥ the required level. */ -const TRUST_RANK: Record = { autonomous: 0, human_present: 1, human_confirmed: 2 }; - -/** - * Check a verified AIT against the gate's `trust_level` / `auth.amr` requirements (the spec's - * human-presence gate). Returns a detail string when insufficient (→ weak_auth), else null. - */ -export const checkTrustRequirements = ( - payload: { trust_level?: string | undefined; auth?: { amr?: string[] | undefined } | undefined }, - requiredTrustLevel?: TrustLevel, - requiredAmr?: string[], -): string | null => { - if (requiredTrustLevel !== undefined) { - const have = TRUST_RANK[payload.trust_level ?? 'autonomous'] ?? 0; - const need = TRUST_RANK[requiredTrustLevel] ?? 0; - if (have < need) { - return `This endpoint requires trust_level '${requiredTrustLevel}'; the token asserts '${payload.trust_level ?? 'autonomous'}'. Re-mint an AIT at the required trust level (human confirmation).`; - } - } - if (requiredAmr !== undefined && requiredAmr.length > 0) { - const amr = Array.isArray(payload.auth?.amr) ? payload.auth!.amr! : []; - if (!amr.some((m) => requiredAmr.includes(m))) { - return `This endpoint requires an authentication method in [${requiredAmr.join(', ')}]; the token carries [${amr.join(', ') || 'none'}].`; - } - } - return null; -}; - -/** Build an RFC 9457 `weak_auth` body for a token that failed the trust_level / auth.amr gate. */ -export const buildAipWeakAuthBody = (opts: { - detail: string; - requiredTrustLevel?: TrustLevel; - requiredAmr?: string[]; - trustedIssuers?: string[]; -}): AipErrorBody => ({ - type: 'urn:aip:error:weak_auth', - title: 'weak auth', - status: 403, - detail: opts.detail, - ...(opts.requiredTrustLevel !== undefined && { required_trust_level: opts.requiredTrustLevel }), - ...(opts.requiredAmr !== undefined && opts.requiredAmr.length > 0 && { required_amr: opts.requiredAmr }), - ...(opts.trustedIssuers !== undefined && opts.trustedIssuers.length > 0 && { trusted_issuers: opts.trustedIssuers }), -}); - -/** A verified AIT, or a ready-to-render RFC 9457 denial body (HTTP status on `body.status`). */ -export type AipGateEvaluation = - | { ok: true; ait: VerifiedAit } - | { ok: false; body: AipErrorBody }; - -/** Collect the merchant's requirements from gate options, for attaching to denial bodies. */ -const requirementsFromOptions = (opts: AipGateOptions): AipErrorRequirements => ({ - ...(opts.trustedIssuers !== undefined && { trustedIssuers: opts.trustedIssuers }), - ...(opts.requiredClaims !== undefined && { requiredClaims: opts.requiredClaims }), - ...(opts.requireTrustLevel !== undefined && { requiredTrustLevel: opts.requireTrustLevel }), - ...(opts.requireAmr !== undefined && { requiredAmr: opts.requireAmr }), -}); - -/** - * Verify the AIP credential AND enforce the gate's trust_level / auth.amr requirement in one call: - * the standalone-adapter counterpart to {@link verifyAitRequest}. Returns the verified AIT, or an - * RFC 9457 denial body (a verify failure → its wire code; trust insufficient → weak_auth) carrying - * the merchant's `required_*` / `trusted_issuers` so the agent can self-correct. - */ -const evaluateFromContext = async (ctx: VerifyRequestContext, opts: AipGateOptions): Promise => { - const result = await verifyAit(ctx, { jwks: opts.jwks, now: opts.now, maxSkewSeconds: opts.maxSkewSeconds }); - if (!result.ok) { - return { ok: false, body: buildAipErrorBody(result.reason, requirementsFromOptions(opts)) }; - } - const weak = checkTrustRequirements(result.ait.payload, opts.requireTrustLevel, opts.requireAmr); - if (weak !== null) { - return { - ok: false, - body: buildAipWeakAuthBody({ - detail: weak, - ...(opts.requireTrustLevel !== undefined && { requiredTrustLevel: opts.requireTrustLevel }), - ...(opts.requireAmr !== undefined && { requiredAmr: opts.requireAmr }), - ...(opts.trustedIssuers !== undefined && { trustedIssuers: opts.trustedIssuers }), - }), - }; - } - return { ok: true, ait: result.ait }; -}; - -/** Verify + trust-enforce on a standard Fetch `Request` (Hono / Web / Next.js adapters). */ -export const evaluateAipRequest = (req: Request, opts: AipGateOptions): Promise => - evaluateFromContext(buildVerifyContextFromRequest(req, opts.authority), opts); - -/** Verify + trust-enforce from Express/Fastify-style parts (Node header map + method + url). */ -export const evaluateAipParts = ( - parts: { method: string; url: string; headers: Record; authority?: string }, - opts: AipGateOptions, -): Promise => evaluateFromContext(buildVerifyContextFromParts(partsWithAuthority(parts, opts)), opts); diff --git a/src/aip/http-signature.ts b/src/aip/http-signature.ts deleted file mode 100644 index 6950ce5..0000000 --- a/src/aip/http-signature.ts +++ /dev/null @@ -1,505 +0,0 @@ -/** - * RFC 9421 HTTP Message Signatures: the AIP-constrained subset. - * - * AIP (Agentic Identity Protocol) binds an Agent Identity Token (AIT) to the - * agent that presents it: the agent signs each HTTP request with the private key whose - * public half is carried in the AIT's `cnf.jwk` (RFC 7800). A verifier reconstructs the - * RFC 9421 signature base, verifies it against `cnf.jwk`, and confirms the signature's - * `keyid` equals the JWK thumbprint (RFC 7638) of that key. A stolen AIT is then useless - * without the bound private key. - * - * This module implements ONLY the shape AIP uses, not the full RFC 9421 grammar: - * - * - Covered components: the derived components `@method`, `@authority`, `@path`, plus the - * `agent-identity` header field. (The AIP "minimum required" set.) Extra components in a - * presented signature are accepted and covered if the caller supplies their values. - * - One labeled signature per request, tagged `tag="agent-identity"`. Web Bot Auth - * signatures (`tag="web-bot-auth"`) may coexist on the same request under a different - * label; we select ours by tag, ignoring the rest. - * - Algorithm: Ed25519 (EdDSA over OKP/Ed25519). AIP's default and only signing curve. - * - * The structured-field parsing here is deliberately narrow: it parses the AIP member of the - * `Signature-Input` / `Signature` dictionaries (a parenthesized inner list + integer/string - * params, and a single byte-sequence value). It is not a general RFC 8941 parser. - */ - -import { calculateJwkThumbprint, importJWK, type JWK } from 'jose'; - -const { subtle } = globalThis.crypto; - -/** Runtime-agnostic base64 (standard, not base64url) codecs. The verify path runs on every AIT - * check, including the Fetch-native web / nextjs adapters that target Cloudflare Workers / Vercel - * Edge where the Node `Buffer` global is undefined: so decode/encode via `atob`/`btoa`, which are - * available on every standards runtime (and Node ≥16). */ -const b64ToBytes = (b64: string): Uint8Array => { - const bin = atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) { out[i] = bin.charCodeAt(i); } - return out; -}; - -const bytesToB64 = (bytes: Uint8Array): string => { - let bin = ''; - for (let i = 0; i < bytes.length; i++) { bin += String.fromCharCode(bytes[i]!); } - return btoa(bin); -}; - -/** 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 clock-skew tolerance (seconds) for `created` / `expires`. Aligned to the AIP spec's - * recommended 60s window (and to the JWT iat/exp tolerance) so the whole AIP check uses one value. */ -const DEFAULT_MAX_SKEW_SECONDS = 60; - -/** Hard ceiling on the PoP signature's own declared lifetime (`expires - created`), in seconds. - * Requiring `created`+`expires` bounds replay to the declared window: but with no ceiling a - * malicious trusted-issuer agent could set `expires = created + (AIT lifetime)` and replay for the - * full window. Cap it tightly so every accepted PoP is short-lived. First-party `pay` signs a 60s - * window, so it passes; this only bites a signer that declares an over-long PoP. Matches the - * authoritative API verifier's `MAX_POP_WINDOW_SECONDS` (the AgentScore API verifier) so the - * edge (standalone `aipGate`) and the API can't drift. (Distinct from the AIT JWT's `exp - iat` - * ceiling in verify.ts: this is the HTTP-signature layer.) */ -export const MAX_POP_WINDOW_SECONDS = 120; - -/** Parameters parsed from (or used to build) a `Signature-Input` member. */ -export interface SignatureParams { - components: string[]; - created?: number; - expires?: number; - keyid?: string; - tag?: string; - alg?: string; -} - -export interface VerifyMessageSignatureInput { - /** HTTP method, e.g. `POST`. Case-insensitive; normalized to upper. */ - method: string; - /** Authority (host[:port]), e.g. `wine-merchant.com`. Lowercased; default ports dropped. */ - authority: string; - /** Request path (no query), e.g. `/checkout`. */ - path: string; - /** Raw value of the `Agent-Identity` header the signature covers. */ - agentIdentity: string; - /** Raw `Signature-Input` header value. */ - signatureInput: string; - /** Raw `Signature` header value. */ - signature: string; - /** The agent's public key from the AIT's `cnf.jwk`. */ - cnfJwk: JWK; - /** Wall-clock seconds; defaults to now. Injectable for tests. */ - now?: number; - /** Skew tolerance for created/expires. Defaults to {@link DEFAULT_MAX_SKEW_SECONDS}. */ - maxSkewSeconds?: number; - /** Extra covered-component values, keyed by component name (for components beyond the AIP minimum). */ - extraComponents?: Record; -} - -export type VerifyFailureReason = - | 'no_aip_signature' - | 'malformed_signature_input' - | 'malformed_signature' - | 'unsupported_alg' - | 'missing_keyid' - | 'keyid_mismatch' - | 'missing_covered_component' - | 'created_missing' - | 'expires_missing' - | 'pop_window_too_long' - | 'created_in_future' - | 'expired' - | 'unsupported_cnf_key' - | 'signature_invalid'; - -export type VerifyMessageSignatureResult = - | { ok: true; params: SignatureParams } - | { ok: false; reason: VerifyFailureReason }; - -/** - * Normalize an authority for `@authority` per RFC 9421 §2.2.3: lowercase, drop the default - * port for the scheme. We don't know the scheme here, so we drop the common defaults (80/443). - */ -export const normalizeAuthority = (authority: string): string => { - const lower = authority.trim().toLowerCase(); - const colon = lower.lastIndexOf(':'); - if (colon === -1) { return lower; } - // Guard against IPv6 literals like `[::1]` with no port. - if (lower.includes(']') && colon < lower.indexOf(']')) { return lower; } - const host = lower.slice(0, colon); - const port = lower.slice(colon + 1); - if (port === '80' || port === '443') { return host; } - return lower; -}; - -/** Serialize one derived/header component value into its signature-base line value. */ -const componentValue = ( - name: string, - input: { method: string; authority: string; path: string; agentIdentity: string; extra?: Record }, -): string | null => { - 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: - return input.extra?.[name] ?? null; - } -}; - -/** Serialize the inner-list of covered components: `("@method" "@authority" ...)`. */ -const serializeComponentList = (components: string[]): string => - `(${components.map((c) => `"${c}"`).join(' ')})`; - -/** Serialize the `;k=v` params suffix in canonical order. */ -const 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}`); } - if (p.keyid !== undefined) { parts.push(`keyid="${p.keyid}"`); } - if (p.alg !== undefined) { parts.push(`alg="${p.alg}"`); } - if (p.tag !== undefined) { 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. Components are joined by `\n` with no trailing newline. - * Throws if a covered component has no available value. - * - * On the VERIFY path, pass `rawSignatureParams`: the member value exactly as received from - * `Signature-Input`: so the base reproduces the signer's serialization byte-for-byte regardless - * of the order they emitted the params in (RFC 9421 §2.3 puts no order on them). Without it the - * line is re-serialized in our canonical order (the SIGN path), which would wrongly reject a - * spec-legal signer that ordered params differently. - */ -export const buildSignatureBase = ( - params: SignatureParams, - input: { method: string; authority: string; path: string; agentIdentity: string; extra?: Record }, - rawSignatureParams?: string, -): string => { - const lines: string[] = []; - for (const name of params.components) { - const value = componentValue(name, input); - if (value === null) { - throw new MissingComponentError(name); - } - lines.push(`"${name}": ${value}`); - } - const paramsValue = rawSignatureParams ?? serializeComponentList(params.components) + serializeParams(params); - lines.push(`"@signature-params": ${paramsValue}`); - return lines.join('\n'); -}; - -class MissingComponentError extends Error { - constructor(public component: string) { - super(`signature base missing covered component: ${component}`); - this.name = 'MissingComponentError'; - } -} - -/** - * Parse a `Signature-Input` dictionary and return the member tagged `tag`. The tag is REQUIRED - * (the AIP spec mandates `tag="agent-identity"`): an untagged member is skipped like any - * wrong-tagged member. `rawParams` is the member's value exactly as received (the inner list + - * its parameters, byte-for-byte, trimmed of surrounding OWS only): the verifier echoes it into - * the `"@signature-params"` base line so the signer's param order is preserved. - * Returns null if no AIP member is found or the member is malformed. - */ -export const parseSignatureInput = (header: string, tag = AIP_SIGNATURE_TAG): { label: string; params: SignatureParams; rawParams: string } | null => { - const members = splitDictionary(header); - if (members.length === 0) { return null; } - - const parsed = members - .map((m) => { - const params = parseInnerListMember(m.value); - return params ? { label: m.label, params, rawParams: m.value } : null; - }) - .filter((x): x is { label: string; params: SignatureParams; rawParams: string } => x !== null); - - if (parsed.length === 0) { return null; } - - return parsed.find((p) => p.params.tag === tag) ?? null; -}; - -/** - * Parse a `Signature` dictionary and return the byte-sequence value for `label`. - * RFC 8941 byte sequences are `::`. - */ -export const parseSignatureValue = (header: string, label: string): Uint8Array | null => { - const members = splitDictionary(header); - const member = members.find((m) => m.label === label); - if (!member) { return null; } - const v = member.value.trim(); - if (!v.startsWith(':') || !v.endsWith(':') || v.length < 2) { return null; } - const b64 = v.slice(1, -1); - try { - return b64ToBytes(b64); - } catch { - return null; - } -}; - -/** A single `label=value` member of a structured-field dictionary. */ -interface DictMember { label: string; value: string; } - -/** - * Split a dictionary header into `label=value` members at top-level commas, respecting - * parentheses (inner lists) and colon-delimited byte sequences so commas inside them - * don't split. Narrow but correct for the AIP shapes we emit/consume. - */ -const splitDictionary = (header: string): DictMember[] => { - const out: DictMember[] = []; - let depth = 0; - let inBytes = false; - let inString = false; - let current = ''; - for (let i = 0; i < header.length; i++) { - const ch = header[i]; - if (inString) { - current += ch; - if (ch === '"' && header[i - 1] !== '\\') { inString = false; } - continue; - } - if (ch === '"') { inString = true; current += ch; continue; } - if (ch === ':') { inBytes = !inBytes; current += ch; continue; } - if (!inBytes && ch === '(') { depth++; current += ch; continue; } - if (!inBytes && ch === ')') { depth = Math.max(0, depth - 1); current += ch; continue; } - if (!inBytes && depth === 0 && ch === ',') { - pushMember(out, current); - current = ''; - continue; - } - current += ch; - } - pushMember(out, current); - return out; -}; - -const pushMember = (out: DictMember[], raw: string): void => { - const trimmed = raw.trim(); - if (!trimmed) { return; } - const eq = trimmed.indexOf('='); - if (eq === -1) { return; } - const label = trimmed.slice(0, eq).trim(); - const value = trimmed.slice(eq + 1).trim(); - if (label) { out.push({ label, value }); } -}; - -/** Parse an inner-list member value: `("@method" ...);created=...;keyid="...";tag="..."`. */ -const parseInnerListMember = (value: string): SignatureParams | null => { - const open = value.indexOf('('); - const close = value.indexOf(')', open + 1); - if (open === -1 || close === -1) { return null; } - const listBody = value.slice(open + 1, close).trim(); - const components = listBody.length === 0 - ? [] - : (listBody.match(/"[^"]*"/g) ?? []).map((s) => s.slice(1, -1)); - - const params: SignatureParams = { components }; - const paramStr = value.slice(close + 1); - // Match ;key=value where value is an integer or a quoted string. - const re = /;\s*([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*("(?:[^"\\]|\\.)*"|-?\d+)/g; - let match: RegExpExecArray | null; - while ((match = re.exec(paramStr)) !== null) { - const key = match[1]; - const raw = match[2]; - const val = raw.startsWith('"') ? raw.slice(1, -1) : Number(raw); - if (key === 'created') { params.created = val as number; } - else if (key === 'expires') { params.expires = val as number; } - else if (key === 'keyid') { params.keyid = val as string; } - else if (key === 'tag') { params.tag = val as string; } - else if (key === 'alg') { params.alg = val as string; } - } - return params; -}; - -/** - * Verify an AIP HTTP Message Signature. Performs the full check: - * 1. select the AIP-tagged member of `Signature-Input` - * 2. confirm the AIP minimum covered components are present - * 3. REQUIRE both `created` and `expires`, reject an over-long declared window - * (`expires - created` > MAX_POP_WINDOW_SECONDS → `pop_window_too_long`), then enforce them - * against `now` with skew tolerance. Both are mandatory: an optional time bound is no time - * bound: without `expires` a captured `(token, Signature-Input, Signature)` triple is - * replayable for the whole AIT lifetime. A signature omitting either is rejected - * (`created_missing` / `expires_missing`). This matches the authoritative API verifier - * (the AgentScore API verifier) so a merchant running `aipGate` STANDALONE (the - * crypto-identity-only deployment with no `/v1/assess`) gets the same replay defense. - * 4. confirm `keyid` equals the RFC 7638 thumbprint of `cnf.jwk` - * 5. reconstruct the signature base and verify Ed25519 over it - * - * NOTE: this is a STATELESS verifier: it bounds the replay WINDOW but does not dedupe within it. - * A captured triple can still be replayed until `expires` (≤ MAX_POP_WINDOW_SECONDS + skew from - * `created`). A stateful seen-signature cache (as in the authoritative API) is out of scope for the - * SDK edge; the tight window bound is the meaningful mitigation here. - */ -export const verifyMessageSignature = async ( - input: VerifyMessageSignatureInput, -): Promise => { - const selected = parseSignatureInput(input.signatureInput); - if (!selected) { return { ok: false, reason: 'no_aip_signature' }; } - const { label, params, rawParams } = selected; - - // The `alg` param is optional in RFC 9421 (the verifier derives the algorithm from the key); - // when a signer does include it, the registered HTTP-sig label is `ed25519`. Accept that plus the - // JWS spelling `EdDSA`, case-insensitively, so a spec-loose external signer isn't wrongly rejected - // the actual key type is still pinned to OKP/Ed25519 below, so this only affects the label. - if (params.alg !== undefined && !['ed25519', 'eddsa'].includes(params.alg.toLowerCase())) { - return { ok: false, reason: 'unsupported_alg' }; - } - - // All AIP-minimum components must be covered. - for (const required of AIP_COVERED_COMPONENTS) { - if (!params.components.includes(required)) { - return { ok: false, reason: 'missing_covered_component' }; - } - } - - // REQUIRE both `created` and `expires`. Treating them as optional leaves an unbounded replay - // window: a captured signature with no `expires` is valid for the AIT's full lifetime. Reject - // when either is absent so every accepted PoP carries an explicit, enforceable time bound. (Our - // pay signer always emits both with a 60s window; this only rejects spec-loose external signers.) - if (params.created === undefined) { return { ok: false, reason: 'created_missing' }; } - if (params.expires === undefined) { return { ok: false, reason: 'expires_missing' }; } - - // Bound the PoP's own declared lifetime. created+expires alone only bound replay to whatever - // window the SIGNER chose: a malicious trusted-issuer agent could declare a window as wide as the - // AIT lifetime and replay for all of it. Reject an over-long window so every accepted PoP is - // short-lived. (pay signs 60s; this only bites a signer declaring > MAX_POP_WINDOW_SECONDS.) - // A NEGATIVE window (expires before created) is equally malformed: without the explicit check - // it would slip under the cap (negative < 120). - if (params.expires < params.created || params.expires - params.created > MAX_POP_WINDOW_SECONDS) { - return { ok: false, reason: 'pop_window_too_long' }; - } - - const now = input.now ?? Math.floor(Date.now() / 1000); - const skew = input.maxSkewSeconds ?? DEFAULT_MAX_SKEW_SECONDS; - if (params.created > now + skew) { - return { ok: false, reason: 'created_in_future' }; - } - if (params.expires < now - skew) { - return { ok: false, reason: 'expired' }; - } - - if (!params.keyid) { return { ok: false, reason: 'missing_keyid' }; } - - // The RFC 9421 proof-of-possession is verified with the agent's `cnf` key, which AIP binds as - // Ed25519 (OKP). Validate the key shape BEFORE thumbprinting / importing: a malformed JWK - // (missing or non-string `x`) makes `calculateJwkThumbprint` throw, and a non-OKP key (e.g. a - // P-256 EC cnf) makes `importJWK(... 'EdDSA')` throw JOSENotSupported. Neither call site below - // catches, so an unguarded throw would crash the gate: reject with a typed failure instead. - // (Note: the JWT alg allowlist permits ES256 for the IDP *issuer* signing key, a different key.) - const cnf = input.cnfJwk as { kty?: unknown; crv?: unknown; x?: unknown }; - if (cnf.kty !== 'OKP' || cnf.crv !== 'Ed25519' || typeof cnf.x !== 'string' || cnf.x.length === 0) { - return { ok: false, reason: 'unsupported_cnf_key' }; - } - - let thumbprint: string; - try { - thumbprint = await calculateJwkThumbprint(input.cnfJwk, 'sha256'); - } catch { - return { ok: false, reason: 'unsupported_cnf_key' }; - } - if (params.keyid !== thumbprint) { return { ok: false, reason: 'keyid_mismatch' }; } - - const sig = parseSignatureValue(input.signature, label); - if (!sig) { return { ok: false, reason: 'malformed_signature' }; } - - let base: string; - try { - base = buildSignatureBase(params, { - method: input.method, - authority: input.authority, - path: input.path, - agentIdentity: input.agentIdentity, - extra: input.extraComponents, - }, rawParams); - } catch (err) { - if (err instanceof MissingComponentError) { return { ok: false, reason: 'missing_covered_component' }; } - throw err; - } - - // cnf key shape (OKP/Ed25519 with a string `x`) was validated above, before thumbprinting. - let valid: boolean; - try { - const key = await importJWK(input.cnfJwk, 'EdDSA'); - if (!(key instanceof CryptoKey)) { - // jose returns a Uint8Array for symmetric keys; Ed25519 public is always a CryptoKey. - return { ok: false, reason: 'signature_invalid' }; - } - valid = await subtle.verify( - { name: 'Ed25519' }, - key, - sig as unknown as ArrayBuffer, - new TextEncoder().encode(base) as unknown as ArrayBuffer, - ); - } catch { - // Any crypto/import failure is a verification failure, never an uncaught throw. - return { ok: false, reason: 'signature_invalid' }; - } - return valid ? { ok: true, params } : { ok: false, reason: 'signature_invalid' }; -}; - -export interface SignMessageInput { - method: string; - authority: string; - path: string; - agentIdentity: string; - /** Agent private key (Ed25519 JWK with `d`). */ - privateJwk: JWK; - /** Agent public key; used to derive `keyid` (thumbprint). */ - publicJwk: JWK; - created?: number; - expires?: number; - /** Signature dictionary label. Defaults to `ait`. */ - label?: string; - components?: string[]; - extraComponents?: Record; -} - -/** Build the `Signature-Input` and `Signature` header values for an AIP request. */ -export const signMessage = async ( - input: SignMessageInput, -): Promise<{ signatureInput: string; signature: string }> => { - const label = input.label ?? 'ait'; - const components = input.components ?? [...AIP_COVERED_COMPONENTS]; - const created = input.created ?? Math.floor(Date.now() / 1000); - const keyid = await calculateJwkThumbprint(input.publicJwk, 'sha256'); - - const params: SignatureParams = { - components, - created, - expires: input.expires, - keyid, - tag: AIP_SIGNATURE_TAG, - }; - - const base = buildSignatureBase(params, { - method: input.method, - authority: input.authority, - path: input.path, - agentIdentity: input.agentIdentity, - extra: input.extraComponents, - }); - - const key = await importJWK(input.privateJwk, 'EdDSA'); - if (!(key instanceof CryptoKey)) { - throw new Error('signMessage: expected an Ed25519 private CryptoKey'); - } - const sigBytes = await subtle.sign( - 'Ed25519', - key, - new TextEncoder().encode(base) as unknown as ArrayBuffer, - ); - const b64 = bytesToB64(new Uint8Array(sigBytes)); - - const signatureInput = `${label}=${serializeComponentList(components)}${serializeParams(params)}`; - const signature = `${label}=:${b64}:`; - return { signatureInput, signature }; -}; diff --git a/src/aip/jwks.ts b/src/aip/jwks.ts deleted file mode 100644 index 260e4fc..0000000 --- a/src/aip/jwks.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * AIP IdP key discovery: fetch, cache, and select JWKS signing keys. - * - * Verifiers resolve an IdP's public keys from `https://{iss}/.well-known/agent-identity/jwks.json` - * (the spec's well-known path). This module owns: - * - * - **Trusted-issuer enforcement**: only `iss` values on the allowlist are fetched, compared - * after URL canonicalization (lowercase scheme+host, no default port, no trailing slash) so - * `https://issuer.example` and `https://issuer.example/` match. - * - **HTTPS-only**: JWKS over plain HTTP is MITM-vulnerable; we refuse it. - * - **Caching with a HARD cap**: we honor `Cache-Control: max-age` as advisory but never - * cache longer than {@link HARD_MAX_CACHE_SECONDS}, regardless of what the IdP sends. A - * compromised IdP can't pin stale keys with `max-age=31536000`. - * - **kid-miss refresh**: a lookup for a `kid` not in the cached set triggers one refetch - * (rotation may have published a new key inside the cache window). - * - **use:"sig" filtering**: only signing keys are returned. - * - * Pure-ish: the only I/O is `fetch`, injectable for tests. - */ - -import type { JWK } from 'jose'; - -/** The spec's well-known JWKS path, relative to the issuer origin. */ -export const JWKS_WELL_KNOWN_PATH = '/.well-known/agent-identity/jwks.json'; - -/** Hard ceiling on cache age, regardless of IdP-supplied Cache-Control. */ -export const HARD_MAX_CACHE_SECONDS = 86_400; // 24h - -/** Floor used when the IdP sends no usable cache directive. */ -export const DEFAULT_CACHE_SECONDS = 300; // 5m - -/** - * After a fetch ATTEMPT (initial, kid-miss-triggered, success OR failure), suppress further - * upstream fetches for this issuer for this many ms. Mirrors the API verifier's - * `cooldownDuration: 30_000` (jose `createRemoteJWKSet`, `the AgentScore API verifier`). Without - * it, an unauthenticated attacker who sends a stream of tokens with unknown `kid`s (the `kid`/`iss` - * are decoded BEFORE signature verification, and the canonical issuer is always trusted) forces one - * upstream JWKS GET per request: a refetch-amplification / DoS vector against the issuer. Stamping - * the cooldown on FAILURE too (a negative cache) closes the cold/erroring-issuer variant: a stream - * of tokens against an issuer whose JWKS is down would otherwise retry the GET on every request. - */ -export const REFETCH_COOLDOWN_MS = 30_000; // 30s: matches the API verifier - -type FetchLike = (url: string, init?: { headers?: Record }) => Promise<{ - ok: boolean; - status: number; - headers: { get(name: string): string | null }; - json(): Promise; -}>; - -/** AgentScore's own AIT issuer. ALWAYS trusted by every {@link JwksCache} (and therefore every - * gate/adapter built on it) without the merchant listing it: this SDK is the AgentScore - * verifier, so a merchant can't accidentally fail to trust AgentScore-issued AITs. `trustedIssuers` - * only needs to name ADDITIONAL external issuers. */ -export const AGENTSCORE_CANONICAL_ISSUER = 'https://www.agentscore.com'; - -export interface JwksCacheOptions { - /** ADDITIONAL external issuer URLs to trust beyond AgentScore's own (compared after - * canonicalization). AgentScore's canonical issuer is always trusted; omit/empty to accept - * only AgentScore-issued AITs. */ - trustedIssuers?: string[]; - /** Injectable fetch (defaults to global fetch). */ - fetchImpl?: FetchLike; - /** Injectable clock (ms), for tests. */ - now?: () => number; - /** User-Agent for JWKS requests. */ - userAgent?: string; -} - -export type JwksLookupFailure = - | 'untrusted_issuer' - | 'insecure_issuer' - | 'fetch_failed' - | 'malformed_jwks' - | 'key_not_found'; - -export type JwksLookupResult = - | { ok: true; key: JWK } - | { ok: false; reason: JwksLookupFailure }; - -interface CachedKeys { - keys: JWK[]; - expiresAt: number; // ms - /** Until this timestamp (ms), no lookup will hit upstream: a kid-miss within a fresh cache - * returns `key_not_found`, and a cold/expired cache returns `lastFailure` without fetching. - * Stamped to `now + REFETCH_COOLDOWN_MS` on every fetch ATTEMPT (success or failure). This is - * the refetch-amplification / DoS guard (see {@link REFETCH_COOLDOWN_MS}). */ - cooldownUntil: number; // ms - /** Why the last fetch attempt failed, when it did. Served (without refetching) to lookups that - * land within the cooldown with no usable cached keys: the negative-cache entry for a - * cold/erroring issuer. Absent after a successful fetch. */ - lastFailure?: JwksLookupFailure; -} - -/** - * Canonicalize an issuer URL for trust-list comparison: lowercase scheme + host, drop the - * default port for the scheme, strip a trailing slash on an empty path. Returns null if the - * input is not a parseable absolute URL. - */ -export const canonicalizeIssuer = (iss: string): string | null => { - let url: URL; - try { - url = new URL(iss.trim()); - } catch { - return null; - } - const scheme = url.protocol.toLowerCase(); - const host = url.hostname.toLowerCase(); - const isDefaultPort = - url.port === '' || - (scheme === 'https:' && url.port === '443') || - (scheme === 'http:' && url.port === '80'); - const portPart = isDefaultPort ? '' : `:${url.port}`; - // Drop a trailing slash when the path is just "/". - const path = url.pathname === '/' ? '' : url.pathname.replace(/\/$/, ''); - return `${scheme}//${host}${portPart}${path}`; -}; - -/** Parse `Cache-Control: max-age=N`, clamped to the hard cap. Returns seconds. */ -export const resolveCacheSeconds = (cacheControl: string | null): number => { - if (!cacheControl) { return DEFAULT_CACHE_SECONDS; } - if (/\bno-store\b/i.test(cacheControl) || /\bno-cache\b/i.test(cacheControl)) { - return DEFAULT_CACHE_SECONDS; - } - const m = /\bmax-age\s*=\s*(\d+)/i.exec(cacheControl); - if (!m) { return DEFAULT_CACHE_SECONDS; } - const advertised = Number(m[1]); - if (!Number.isFinite(advertised) || advertised <= 0) { return DEFAULT_CACHE_SECONDS; } - return Math.min(advertised, HARD_MAX_CACHE_SECONDS); -}; - -/** Extract `use: "sig"` keys (or keys with no `use`, which default to usable for sig). */ -const signingKeys = (keys: JWK[]): JWK[] => - keys.filter((k) => k.use === undefined || k.use === 'sig'); - -/** - * JWKS resolver bound to a trusted-issuer allowlist. One instance can serve many issuers; - * each issuer's key set is cached independently. - */ -export class JwksCache { - private readonly trusted: Set; - private readonly fetchImpl: FetchLike; - private readonly now: () => number; - private readonly userAgent: string; - private readonly cache = new Map(); - /** Per-issuer in-flight refresh promise: coalesces concurrent refreshes to ONE upstream fetch. - * Without it, a concurrent burst of distinct-kid lookups on a cold/expired cache each call - * `refresh()` before any has populated the cache → N parallel JWKS GETs (refetch amplification). - * The cooldown only suppresses SEQUENTIAL refetches; single-flight suppresses CONCURRENT ones. - * Entry is cleared in a `finally` once the fetch settles. */ - private readonly inflight = new Map>(); - - constructor(opts: JwksCacheOptions) { - // AgentScore's own issuer is always trusted, plus any additional external issuers. Canonicalize - // both so a merchant-supplied duplicate (or trailing-slash variant) of the canonical issuer - // collapses into the same Set entry. - this.trusted = new Set( - [AGENTSCORE_CANONICAL_ISSUER, ...(opts.trustedIssuers ?? [])] - .map(canonicalizeIssuer) - .filter((s): s is string => s !== null), - ); - this.fetchImpl = opts.fetchImpl ?? (globalThis.fetch as unknown as FetchLike); - this.now = opts.now ?? Date.now; - this.userAgent = opts.userAgent ?? '@agentscore/commerce'; - } - - /** Is this issuer on the canonicalized trust list? */ - isTrusted(iss: string): boolean { - const canon = canonicalizeIssuer(iss); - return canon !== null && this.trusted.has(canon); - } - - /** - * Resolve the signing key for `(iss, kid)`. Enforces trust + HTTPS, serves from cache when - * fresh, and refetches once on a kid-miss before giving up. - */ - async getKey(iss: string, kid: string | undefined): Promise { - const canon = canonicalizeIssuer(iss); - if (canon === null || !this.trusted.has(canon)) { - return { ok: false, reason: 'untrusted_issuer' }; - } - if (!canon.startsWith('https://')) { - return { ok: false, reason: 'insecure_issuer' }; - } - - const cached = this.cache.get(canon); - if (cached && this.now() < cached.expiresAt) { - const hit = this.select(cached.keys, kid); - if (hit) { return { ok: true, key: hit }; } - // kid miss within the cache window. Normally we'd force one refetch (rotation may have - // published a new key): but only once the refetch cooldown has elapsed. WITHIN the - // cooldown we return key_not_found WITHOUT refetching. This caps JWKS GETs at ~1 per issuer - // per cooldown regardless of how many unknown-kid tokens an attacker streams (the DoS - // guard). Once the cooldown passes we fall through to a single refetch, because rotation - // may have published the kid since. - if (this.now() < cached.cooldownUntil) { - return { ok: false, reason: 'key_not_found' }; - } - // Past the cooldown: fall through to a single forced refetch below. - } else if (cached && this.now() < cached.cooldownUntil) { - // No usable cached keys (cold fetch failed, or the cache expired) and still inside the - // refetch cooldown: fail WITHOUT an upstream GET. This is the negative cache for a - // cold/erroring issuer: sequential lookups within the cooldown cost zero fetches. - return { ok: false, reason: cached.lastFailure ?? 'key_not_found' }; - } - - const refreshed = await this.refresh(canon); - if (!refreshed.ok) { return refreshed; } - - const hit = this.select(refreshed.keys, kid); - if (hit) { return { ok: true, key: hit }; } - return { ok: false, reason: 'key_not_found' }; - } - - private select(keys: JWK[], kid: string | undefined): JWK | undefined { - const candidates = signingKeys(keys); - if (kid !== undefined) { - const byKid = candidates.find((k) => k.kid === kid); - if (byKid) { return byKid; } - return undefined; - } - // No kid in the token header: only safe when exactly one signing key exists. - return candidates.length === 1 ? candidates[0] : undefined; - } - - /** - * Refresh the cached key set for `canonIssuer`, coalescing concurrent callers onto a single - * upstream fetch (single-flight). The first caller for an issuer with no in-flight refresh kicks - * off the fetch and registers the promise; concurrent callers await that same promise instead of - * issuing their own GET. The entry is cleared once the fetch settles so the NEXT cold/expired - * lookup can refresh again. - */ - private async refresh(canonIssuer: string): Promise<{ ok: true; keys: JWK[] } | { ok: false; reason: JwksLookupFailure }> { - const existing = this.inflight.get(canonIssuer); - if (existing !== undefined) { return existing; } - const promise = this.fetchAndCache(canonIssuer).finally(() => { - this.inflight.delete(canonIssuer); - }); - this.inflight.set(canonIssuer, promise); - return promise; - } - - private async fetchAndCache(canonIssuer: string): Promise<{ ok: true; keys: JWK[] } | { ok: false; reason: JwksLookupFailure }> { - const url = `${canonIssuer}${JWKS_WELL_KNOWN_PATH}`; - let res: Awaited>; - try { - res = await this.fetchImpl(url, { headers: { 'User-Agent': this.userAgent, Accept: 'application/jwk-set+json, application/json' } }); - } catch { - return this.stampFailure(canonIssuer, 'fetch_failed'); - } - if (!res.ok) { return this.stampFailure(canonIssuer, 'fetch_failed'); } - - let body: unknown; - try { - body = await res.json(); - } catch { - return this.stampFailure(canonIssuer, 'malformed_jwks'); - } - if (typeof body !== 'object' || body === null || !Array.isArray((body as { keys?: unknown }).keys)) { - return this.stampFailure(canonIssuer, 'malformed_jwks'); - } - const keys = (body as { keys: JWK[] }).keys; - - const ttlSeconds = resolveCacheSeconds(res.headers.get('cache-control')); - const now = this.now(); - // Stamp the cooldown window on every fetch so a kid-miss can't trigger another refetch for - // REFETCH_COOLDOWN_MS. - this.cache.set(canonIssuer, { - keys, - expiresAt: now + ttlSeconds * 1000, - cooldownUntil: now + REFETCH_COOLDOWN_MS, - }); - return { ok: true, keys }; - } - - /** Stamp the per-issuer refetch cooldown on a FAILED fetch attempt (the negative cache). Any - * previously cached keys + expiry are preserved: a failed kid-miss refetch must not nuke a - * still-fresh key set; only the cooldown and failure reason are updated. */ - private stampFailure(canonIssuer: string, reason: JwksLookupFailure): { ok: false; reason: JwksLookupFailure } { - const now = this.now(); - const prev = this.cache.get(canonIssuer); - this.cache.set(canonIssuer, { - keys: prev?.keys ?? [], - expiresAt: prev?.expiresAt ?? now, - cooldownUntil: now + REFETCH_COOLDOWN_MS, - lastFailure: reason, - }); - return { ok: false, reason }; - } -} diff --git a/src/aip/request.ts b/src/aip/request.ts deleted file mode 100644 index 7338b89..0000000 --- a/src/aip/request.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Build an AIP {@link VerifyRequestContext} from a standard WHATWG `Request`. - * - * Every framework adapter ultimately has (or can produce) a `Request`: Hono exposes - * `c.req.raw`, Next.js / Web Fetch hand you one directly, and Express/Fastify can be shimmed. - * This helper centralizes the header + URL extraction so adapters stay thin and the parsing - * rules (authority derivation, multiple `Agent-Identity` headers) live in one place. - */ - -import { AGENT_IDENTITY_HEADER, type VerifyRequestContext } from './verify'; - -/** - * Read all values of a possibly-repeated header. The Fetch `Headers` object folds repeated - * headers into a single comma-joined value; for `Agent-Identity` we must split them back out - * because each AIT is an independent JWT. JWTs are base64url dot-separated and never contain a - * bare comma, so splitting on `,` is safe. - */ -const readAgentIdentityHeaders = (headers: Headers): string[] => { - const raw = headers.get(AGENT_IDENTITY_HEADER); - if (!raw) { return []; } - return raw - .split(',') - .map((s) => s.trim()) - .filter((s) => s.length > 0); -}; - -/** - * Derive `@authority` for RFC 9421. Prefer the `Host` header (what the client addressed); - * fall back to the URL host. Returned as-is; {@link normalizeAuthority} canonicalizes during - * signature-base construction. - */ -const deriveAuthority = (req: Request, url: URL): string => req.headers.get('host') ?? url.host; - -/** Build the framework-agnostic verify context from a standard `Request`. A configured - * `authority` pin (e.g. `AipGateOptions.authority`) wins over the inbound `Host` header. */ -export const buildVerifyContextFromRequest = (req: Request, authority?: string): VerifyRequestContext => { - const url = new URL(req.url); - return { - method: req.method, - authority: authority ?? deriveAuthority(req, url), - path: url.pathname, - agentIdentityHeaders: readAgentIdentityHeaders(req.headers), - signatureInput: req.headers.get('signature-input'), - signature: req.headers.get('signature'), - }; -}; - -/** True when the request carries an AIP credential (at least one `Agent-Identity` header). */ -export const hasAgentIdentityHeader = (req: Request): boolean => - readAgentIdentityHeaders(req.headers).length > 0; - -/** Read a header from a Node-style header map (value may be string | string[] | undefined). */ -const readNodeHeader = ( - headers: Record, - name: string, -): string | undefined => { - const v = headers[name] ?? headers[name.toLowerCase()]; - if (v === undefined) { return undefined; } - return Array.isArray(v) ? v.join(', ') : v; -}; - -/** True when a Node-style header map carries an `Agent-Identity` header. */ -export const hasAgentIdentityHeaderNode = ( - headers: Record, -): boolean => { - const raw = readNodeHeader(headers, 'agent-identity'); - return raw !== undefined && raw.split(',').some((s) => s.trim().length > 0); -}; - -/** - * Build the verify context from Express/Fastify-style parts (Node header map + method + URL). - * These frameworks don't expose a Fetch `Request`, so adapters pass raw pieces here. - */ -export const buildVerifyContextFromParts = (parts: { - method: string; - /** Full request URL, or just the path. Used to derive `@path`. */ - url: string; - /** Node header map. */ - headers: Record; - /** Authority override; falls back to the `host` header. */ - authority?: string; -}): VerifyRequestContext => { - const agentIdentityRaw = readNodeHeader(parts.headers, 'agent-identity'); - const agentIdentityHeaders = agentIdentityRaw - ? agentIdentityRaw.split(',').map((s) => s.trim()).filter((s) => s.length > 0) - : []; - const host = parts.authority ?? readNodeHeader(parts.headers, 'host') ?? ''; - // url may be an absolute URL or an origin-form target ("/checkout?...", possibly "//x"). Build - // the URL by APPENDING the target to the origin (not resolving it as a reference) so a leading - // "//" is treated as PATH: `new URL('//x', base)` would mis-read "//x" as a protocol-relative - // authority and drop it, diverging from the signer's `URL.pathname` and failing PoP. - // Always assigned in both branches below, so no initializer (avoids a dead assignment). - let path: string; - try { - if (/^[a-z][a-z0-9+.-]*:\/\//i.test(parts.url)) { - path = new URL(parts.url).pathname; - } else { - const target = parts.url.startsWith('/') ? parts.url : `/${parts.url}`; - path = new URL(`http://${host || 'localhost'}${target}`).pathname; - } - } catch { - const q = parts.url.indexOf('?'); - path = q === -1 ? parts.url : parts.url.slice(0, q); - } - return { - method: parts.method, - authority: host, - path, - agentIdentityHeaders, - signatureInput: readNodeHeader(parts.headers, 'signature-input') ?? null, - signature: readNodeHeader(parts.headers, 'signature') ?? null, - }; -}; diff --git a/src/aip/types.ts b/src/aip/types.ts deleted file mode 100644 index 6098551..0000000 --- a/src/aip/types.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * AIP (Agentic Identity Protocol) token types and the claim contract. - * - * An Agent Identity Token (AIT) is a JWT signed by an Identity Provider (IdP). It binds a - * verified human's identity to the specific agent presenting it (via `cnf`, RFC 7800) and - * carries the trust level, authentication method, optional intent, and optional identity - * claims the IdP attests to. - * - * This module is the single source of truth for the claim shape on the verifier side. It - * encodes the spec's required / recommended / optional claims plus the AgentScore extension - * claims (sanctions, jurisdiction, structured id-verification, cross-merchant graph, payment - * signer) we carry when we act as a compliance IdP. - * - * Extensibility contract (per spec): the `identity` object is open. If a claim is present, - * the IdP attests to it; verifiers ignore claims they don't recognize. Absence is the - * "unknown" signal: IdPs do not ship `null` for "not checked". - */ - -import type { JWK } from 'jose'; - -/** Degree of human involvement in issuing this specific AIT. */ -export type TrustLevel = 'autonomous' | 'human_present' | 'human_confirmed'; - -/** - * Authentication Method Reference values (RFC 8176 / IANA AMR registry). Open set: these - * are the values relevant to agent identity; others are valid and pass through. - */ -export type AmrValue = 'face' | 'fpt' | 'hwk' | 'otp' | 'pin' | 'pwd' | 'sms' | 'swk' | 'user' | 'mfa'; - -/** RFC 7800 confirmation claim: binds the AIT to the agent's signing key. */ -interface CnfClaim { - jwk: JWK; -} - -/** Agent metadata. `provider` is required; `instance` is recommended. */ -interface AgentClaim { - provider: string; - instance?: string; -} - -/** How the user authorized THIS AIT (not prior authentication history). */ -interface AuthClaim { - amr?: AmrValue[] | string[]; - /** When the user authenticated for this token (Unix seconds). Mirrors OIDC `auth_time`. */ - time?: number; -} - -/** What the agent intends to do. Optional; verifiers may require it for non-read actions. */ -export interface IntentClaim { - actions?: string[]; - description?: string; -} - -/** AgentScore wallet-binding extension (orthogonal to `cnf`, which binds the agent key). */ -interface PaymentSignerClaim { - address: string; - network: 'evm' | 'solana'; - /** Relationship the IdP attests between signer and the operator graph. */ - match?: 'linked_operator' | 'claimed_operator'; -} - -interface PaymentClaim { - signer?: PaymentSignerClaim; -} - -/** - * Identity claims (presence == IdP attestation). Spec-defined fields plus AgentScore - * compliance extension claims. Open by contract: unknown fields are allowed and ignored. - */ -export interface IdentityClaim { - email?: string; - email_verified?: boolean; - name?: string; - phone?: string; - phone_verified?: boolean; - age_over_18?: boolean; - age_over_21?: boolean; - id_verified?: boolean; - - // --- AgentScore compliance extensions --- - id_verification?: { - provider?: string; - method?: string; - document_type?: string; - verified_at?: number; - }; - /** ISO 3166-1 alpha-2, optionally with ISO 3166-2 subdivision (e.g. "US-CA"). */ - jurisdiction?: string; - sanctions_clear?: boolean; - sanctions_checked_at?: number; - sanctions_providers?: string[]; - linked_wallets?: Array<{ address: string; network: 'evm' | 'solana' }>; - merchants_paid?: number; - first_seen?: number; - - // Open extension space. - [claim: string]: unknown; -} - -/** The decoded AIT JWT payload. */ -export interface AitPayload { - aip_version: string; - iss: string; - sub: string; - iat: number; - exp: number; - cnf: CnfClaim; - agent: AgentClaim; - trust_level?: TrustLevel; - auth?: AuthClaim; - intent?: IntentClaim; - identity?: IdentityClaim; - payment?: PaymentClaim; - [claim: string]: unknown; -} - -/** The decoded AIT JWT header. */ -export interface AitHeader { - alg: string; - typ?: string; - kid?: string; - [param: string]: unknown; -} - -/** - * Structural validation of a decoded AIT payload. Confirms the required claims are present - * and well-typed, and enforces the one normative conditional in the spec: a - * `human_confirmed` token MUST carry at least one `auth.amr` value. - * - * This is shape/contract validation only: it does NOT verify signatures (that's the - * verifier pipeline) and does NOT apply trust policy (that's the gate / `/v1/assess`). - */ -export type AitValidationResult = - | { ok: true; payload: AitPayload } - | { ok: false; reason: AitValidationFailure }; - -type AitValidationFailure = - | 'not_an_object' - | 'missing_aip_version' - | 'missing_iss' - | 'missing_sub' - | 'missing_iat' - | 'missing_exp' - | 'missing_cnf' - | 'missing_agent_provider' - | 'human_confirmed_without_amr'; - -const isObject = (v: unknown): v is Record => - typeof v === 'object' && v !== null && !Array.isArray(v); - -const isNonEmptyString = (v: unknown): v is string => typeof v === 'string' && v.length > 0; - -/** - * Detect whether a decoded JWT payload is an AIT: per spec, an AIT is discriminated by the - * presence of `cnf` + `agent` claims (not the `typ` header). - */ -export const isAitShape = (payload: unknown): boolean => - isObject(payload) && isObject(payload.cnf) && isObject(payload.agent); - -/** Validate the structural contract of a decoded AIT payload. */ -export const validateAitPayload = (payload: unknown): AitValidationResult => { - if (!isObject(payload)) { return { ok: false, reason: 'not_an_object' }; } - if (!isNonEmptyString(payload.aip_version)) { return { ok: false, reason: 'missing_aip_version' }; } - if (!isNonEmptyString(payload.iss)) { return { ok: false, reason: 'missing_iss' }; } - if (!isNonEmptyString(payload.sub)) { return { ok: false, reason: 'missing_sub' }; } - if (typeof payload.iat !== 'number') { return { ok: false, reason: 'missing_iat' }; } - if (typeof payload.exp !== 'number') { return { ok: false, reason: 'missing_exp' }; } - if (!isObject(payload.cnf) || !isObject((payload.cnf as Record).jwk)) { - return { ok: false, reason: 'missing_cnf' }; - } - if (!isObject(payload.agent) || !isNonEmptyString((payload.agent as Record).provider)) { - return { ok: false, reason: 'missing_agent_provider' }; - } - - // Normative conditional: human_confirmed requires auth.amr with at least one value. - if (payload.trust_level === 'human_confirmed') { - const auth = payload.auth; - const amr = isObject(auth) ? auth.amr : undefined; - if (!Array.isArray(amr) || amr.length === 0) { - return { ok: false, reason: 'human_confirmed_without_amr' }; - } - } - - return { ok: true, payload: payload as AitPayload }; -}; diff --git a/src/aip/verify.ts b/src/aip/verify.ts deleted file mode 100644 index e0e8a50..0000000 --- a/src/aip/verify.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * AIP Agent Identity Token (AIT) verification pipeline: the verifier orchestrator. - * - * This is the function a merchant gate calls. It executes the spec's verification steps over - * a presented request, composing the three foundation modules: - * - {@link ./jwks} : trusted-issuer enforcement + key discovery - * - {@link ./http-signature}: RFC 9421 proof-of-possession over the request - * - {@link ./types} : AIT structural contract - * - * Steps (per spec): - * 1. read the `Agent-Identity` header (one or more) - * 2. decode the JWT header (`kid`) + payload; confirm AIT shape (`cnf` + `agent`) - * 3. resolve the IdP's signing key from its JWKS (trusted-issuer + HTTPS enforced) - * 4. verify the IdP signature on the JWT (reject `alg:none`; key is Ed25519) - * 5. check `exp` / `iat` with skew - * 6. extract `cnf.jwk` - * 7. verify the RFC 9421 HTTP Message Signature with `cnf.jwk` - * 8. confirm the signature `keyid` == JWK thumbprint of `cnf.jwk` (done inside step 7) - * - * On success it returns the validated, signature-checked claims. On failure it returns a - * typed reason that maps onto AIP's wire error codes (the gate turns these into 401/403). - */ - -import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from 'jose'; -import { verifyMessageSignature } from './http-signature'; -import { isAitShape, validateAitPayload, type AitPayload } from './types'; -import type { JwksCache } from './jwks'; - -/** Header that carries the AIT JWT. */ -export const AGENT_IDENTITY_HEADER = 'agent-identity'; - -/** Request fields the verifier needs. Framework-agnostic; adapters map their req onto this. */ -export interface VerifyRequestContext { - method: string; - authority: string; - path: string; - /** All `Agent-Identity` header values present on the request (one per IdP). */ - agentIdentityHeaders: string[]; - signatureInput: string | null; - signature: string | null; -} - -export interface VerifyAitOptions { - jwks: JwksCache; - now?: number; - maxSkewSeconds?: number; - /** Max accepted AIT lifetime (`exp - iat`) in seconds. Defense-in-depth vs an external issuer - * minting a long-lived bearer credential. The spec recommends a 60–300s window; default 300 so a - * stolen AIT's usable window stays short even at the edge (standalone `aipGate`, no `/v1/assess`). - * Our own mint is 300s, so first-party tokens sit exactly at the ceiling. Matches the - * authoritative API verifier's default (the AgentScore API verifier), lowered from 3600. */ - maxLifetimeSeconds?: number; -} - -/** - * Failure reasons, aligned with AIP wire error codes. The gate maps: - * no_token / malformed_token / invalid_signature / expired_token → 401 - * untrusted_issuer / weak_auth / invalid_claims → 403 - */ -export type VerifyAitFailure = - | 'no_token' - | 'malformed_token' - | 'untrusted_issuer' - | 'key_unavailable' - | 'idp_signature_invalid' - | 'expired_token' - | 'invalid_claims' - | 'pop_signature_missing' - | 'pop_signature_invalid'; - -export interface VerifiedAit { - payload: AitPayload; - /** The issuer (canonical, as presented). */ - iss: string; - /** The agent's bound public key (`cnf.jwk`). */ - cnfJwk: AitPayload['cnf']['jwk']; - /** The raw JWT string that verified (the winning `Agent-Identity` header value, Bearer - * prefix stripped). Lets a gate forward the exact token to `/v1/assess` as `aip_token`. */ - token: string; - /** The RFC 9421 signature material for this request: forwarded to `/v1/assess` as - * `aip_signature` so the API re-verifies proof-of-possession authoritatively (the edge - * check here is only a fail-fast filter; the API is the source of truth). */ - signatureMaterial: { - method: string; - authority: string; - path: string; - signature_input: string; - signature: string; - }; -} - -export type VerifyAitResult = - | { ok: true; ait: VerifiedAit } - | { ok: false; reason: VerifyAitFailure }; - -/** - * Verify the AIP credential on a request. When multiple `Agent-Identity` headers are present, - * each is tried; the first that fully verifies AND whose `cnf.jwk` matches the request's - * RFC 9421 signature wins (all AITs on one request must share the same `cnf` key, so the PoP - * signature is checked once against the winning key). - */ -export const verifyAit = async ( - ctx: VerifyRequestContext, - opts: VerifyAitOptions, -): Promise => { - if (ctx.agentIdentityHeaders.length === 0) { - return { ok: false, reason: 'no_token' }; - } - if (!ctx.signatureInput || !ctx.signature) { - return { ok: false, reason: 'pop_signature_missing' }; - } - // Captured post-guard (string, not string|null): reused for the local fail-fast PoP check and - // forwarded to /v1/assess so the API can re-verify the same proof-of-possession authoritatively. - const signatureInput = ctx.signatureInput; - const signature = ctx.signature; - - let lastFailure: VerifyAitFailure = 'malformed_token'; - - for (const raw of ctx.agentIdentityHeaders) { - const token = stripBearer(raw); - - // Step 2: decode header + payload, confirm AIT shape. - let header: { alg?: string; kid?: string }; - let payload: unknown; - try { - header = decodeProtectedHeader(token); - payload = decodeJwt(token); - } catch { - lastFailure = 'malformed_token'; - continue; - } - if (header.alg === undefined || header.alg.toLowerCase() === 'none') { - lastFailure = 'malformed_token'; - continue; - } - if (!isAitShape(payload)) { - lastFailure = 'malformed_token'; - continue; - } - - // Structural contract (incl. human_confirmed→amr). - const validated = validateAitPayload(payload); - if (!validated.ok) { - lastFailure = 'invalid_claims'; - continue; - } - const claims = validated.payload; - - // Step 3: resolve IdP key (trusted-issuer + HTTPS enforced inside). - const keyLookup = await opts.jwks.getKey(claims.iss, header.kid); - if (!keyLookup.ok) { - // `untrusted_issuer` (not on the allowlist) and `insecure_issuer` (http:// issuer) are both - // PERMANENT trust failures → 403, not the retryable 503 of `key_unavailable` (a transient - // JWKS-fetch problem). Don't tell an agent to retry a config error it can't fix. - lastFailure = - keyLookup.reason === 'untrusted_issuer' || keyLookup.reason === 'insecure_issuer' - ? 'untrusted_issuer' - : 'key_unavailable'; - continue; - } - - // Step 4 + 5: verify IdP signature; enforce alg match + expiry/skew. The JWT iat/exp tolerance - // and the RFC 9421 PoP `created`/`expires` window are both 60s (the AIP spec's recommended - // window). An explicit `maxSkewSeconds` override, when set, applies to both. - const jwtClockTolerance = opts.maxSkewSeconds ?? 60; - try { - const idpKey = await importJWK(keyLookup.key, normalizeAlg(header.alg)); - await jwtVerify(token, idpKey, { - // Pin the signature algorithm allowlist (RFC 8725 §3.1): also rejects `alg:none`. Without - // this, jose accepts whatever alg the resolved JWK supports, so a trusted IdP publishing a - // non-Ed25519 (e.g. RSA/EC) `use:sig` key would let an attacker present an RS256/ES256 - // token that verifies. Matches the server-side allowlist in the AgentScore API verifier. - algorithms: AIT_SIGNING_ALGS, - clockTolerance: jwtClockTolerance, - currentDate: opts.now !== undefined ? new Date(opts.now * 1000) : undefined, - }); - } catch (err) { - lastFailure = isExpiry(err) ? 'expired_token' : 'idp_signature_invalid'; - continue; - } - - // Spec step 5 also requires `iat` not be in the future. jose's `jwtVerify` validates `exp`/`nbf` - // but does not reject a future `iat` by default, so check it explicitly (same skew tolerance). - const nowSec = opts.now ?? Math.floor(Date.now() / 1000); - if (claims.iat > nowSec + jwtClockTolerance) { - lastFailure = 'expired_token'; - continue; - } - - // Defense-in-depth: reject a long-lived AIT (spec recommends 60–300s). Own mint is exactly 300s, - // so first-party tokens pass at the ceiling; this bites a trusted EXTERNAL issuer minting a - // longer-lived bearer credential. Lowered 3600 → 300 (matching the authoritative API verifier) - // to keep a stolen token's usable window short, complementing the now-mandatory PoP time bound. - if (claims.exp - claims.iat > (opts.maxLifetimeSeconds ?? 300)) { - lastFailure = 'expired_token'; - continue; - } - - // Step 6 + 7 + 8: PoP: verify the RFC 9421 signature against cnf.jwk. - const popResult = await verifyMessageSignature({ - method: ctx.method, - authority: ctx.authority, - path: ctx.path, - // The agent-identity covered component is the BARE AIT (a Bearer prefix, if present, is - // transport that `stripBearer` removed above). Verify over `token`, not `raw`, so the edge and - // the API (which verifies over the forwarded bare aip_token) reconstruct the identical base. - agentIdentity: token, - signatureInput, - signature, - cnfJwk: claims.cnf.jwk, - now: opts.now, - maxSkewSeconds: opts.maxSkewSeconds, - }); - if (!popResult.ok) { - lastFailure = 'pop_signature_invalid'; - continue; - } - - return { - ok: true, - ait: { - payload: claims, - iss: claims.iss, - cnfJwk: claims.cnf.jwk, - token, - signatureMaterial: { - method: ctx.method, - authority: ctx.authority, - path: ctx.path, - signature_input: signatureInput, - signature, - }, - }, - }; - } - - return { ok: false, reason: lastFailure }; -}; - -/** Strip an optional `Bearer ` prefix from a header value. */ -const stripBearer = (value: string): string => { - const trimmed = value.trim(); - return /^bearer\s+/i.test(trimmed) ? trimmed.replace(/^bearer\s+/i, '') : trimmed; -}; - -/** Map a JWT header `alg` to the JOSE alg name jose expects for key import. */ -/** Allowed AIT JWT signature algorithms (RFC 8725 §3.1 allowlist). EdDSA is AIP's default; ES256 - * is permitted for parity with the server-side verifier. Anything else (RS*, HS*, none) is - * rejected at `jwtVerify`, regardless of what alg the token header claims or the resolved JWK - * supports. */ -const AIT_SIGNING_ALGS = ['EdDSA', 'ES256']; - -const normalizeAlg = (alg: string): string => (alg.toLowerCase() === 'eddsa' ? 'EdDSA' : alg); - -/** jose throws JWTExpired with code 'ERR_JWT_EXPIRED' on expiry. */ -const isExpiry = (err: unknown): boolean => - typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ERR_JWT_EXPIRED'; diff --git a/src/challenge/agent_memory.ts b/src/challenge/agent_memory.ts index d772b62..9a06edf 100644 --- a/src/challenge/agent_memory.ts +++ b/src/challenge/agent_memory.ts @@ -44,14 +44,9 @@ export type { AgentMemoryHint }; */ export function firstEncounterAgentMemory({ firstEncounter, - aipTrustedIssuers, }: { firstEncounter: boolean; - /** When the merchant accepts AIP, pass its trusted-issuer list (AgentScore's canonical issuer - * plus any externals) so the hint advertises the `agent_identity` path. Omit for non-AIP - * merchants. */ - aipTrustedIssuers?: string[]; }): AgentMemoryHint | undefined { if (!firstEncounter) return undefined; - return buildAgentMemoryHint(aipTrustedIssuers); + return buildAgentMemoryHint(); } diff --git a/src/checkout.ts b/src/checkout.ts index cfc703b..fdc2739 100644 --- a/src/checkout.ts +++ b/src/checkout.ts @@ -37,9 +37,6 @@ import { normalizeHeadersToLowercase } from './_headers'; import { extractMppxReceiptHeaderFromRaw, extractMppxReceiptMethod } from './_mppx_receipt'; import { denialReasonToBody } from './_response'; import { warnMissingApiKeyOnce } from './_warnings'; -import { buildAipErrorBody, buildAipPolicyDenyBody, buildAipWeakAuthBody, checkTrustRequirements, verifyAitParts } from './aip/gate'; -import { AGENTSCORE_CANONICAL_ISSUER, canonicalizeIssuer, JwksCache } from './aip/jwks'; -import { hasAgentIdentityHeaderNode } from './aip/request'; import { buildAcceptedMethods } from './challenge/accepted_methods'; import { type RailKey, buildAgentInstructions } from './challenge/agent_instructions'; import { firstEncounterAgentMemory } from './challenge/agent_memory'; @@ -81,7 +78,6 @@ import { classifyX402SettleResult, processX402Settle } from './payment/x402_sett import { verifyX402Request } from './payment/x402_validation'; import { mppCredentialPayloadType, zeroAmountCarveOut, type ZeroSettleRail } from './payment/zero-settle'; import { extractPaymentSignerFromAuth } from './signer'; -import type { TrustLevel } from './aip/types'; import type { SignedDiscoveryResponse } from './discovery/well_known'; @@ -288,7 +284,7 @@ export interface CheckoutContext { * survives the token rotating, expiring or being revoked, whereas state keyed on the * token instance is stranded every time one rotates. * - * `undefined` when no gate is configured, on wallet or AIT paths, on anonymous discovery + * `undefined` when no gate is configured, on wallet paths, on anonymous discovery * legs, or when the API has no handle salt configured. * * ORDERING: populated by the gate, which runs AFTER `preValidate`, so it is readable @@ -403,76 +399,10 @@ export interface CheckoutGateConfig { * DenialReason. Return a `GateDenial` to override the canonical body, or * null to use `denialReasonToBody`. */ onDenied?: (ctx: CheckoutContext, reason: DenialReason) => GateDenial | null | Promise; - /** Accept AIP Agent Identity Tokens (AITs) on this route. When set and a request carries - * an `Agent-Identity` header, the gate verifies the token offline (issuer signature via the - * trusted-issuer JWKS + RFC 9421 proof-of-possession) BEFORE the assess call, then sends the - * raw token to `/v1/assess` as `aip_token` so the same wine/age/sanctions policy evaluates - * against the token's attested identity. A present-but-invalid AIT is a hard deny (the gate - * does NOT fall through to wallet / operator-token). Requests with no `Agent-Identity` header - * use the existing wallet / operator-token path unchanged. - * - * Ignored when `runGate` is also set (a custom gate fully owns the flow). Without an `apiKey`, - * a verified AIT is honored offline for identity-only gates, but a gate that declares policy - * fields (KYC / age / sanctions / jurisdiction) without an `apiKey` fails closed - * (`aip_policy_requires_api_key`) since policy can only be evaluated via `/v1/assess`. */ - aip?: AipGateConfig; /** Full escape hatch: replaces the SDK gate flow. */ runGate?: RunGateFn; } -/** AIP acceptance config for {@link CheckoutGateConfig.aip}. */ -export interface AipGateConfig { - /** ADDITIONAL external issuers to trust beyond AgentScore's own (e.g. `['https://issuer.example']`), - * matched after canonicalization. AgentScore's canonical issuer - * ({@link AGENTSCORE_CANONICAL_ISSUER}) is ALWAYS trusted and never needs listing: this SDK - * is the AgentScore verifier, so a merchant can't accidentally fail to trust AgentScore AITs. - * Omit/empty to accept only AgentScore-issued AITs. */ - trustedIssuers?: string[]; - /** Clock-skew tolerance in seconds for the RFC 9421 signature window (and, as an override, - * the AIT JWT `exp`/`iat`). Defaults to 60s for both. */ - maxSkewSeconds?: number; - /** Expected `@authority` (public hostname) the RFC 9421 signature must cover. When set, the - * verifier binds the signature to this value instead of trusting the inbound `Host` header: - * pin it to your real public host (e.g. `'wine.example.com'`) when behind a proxy that does - * not normalize `Host`, to prevent a captured AIT+signature from being replayed to a - * different virtual host on the same origin. */ - authority?: string; - /** Minimum `trust_level` an AIT must assert to pass this gate (autonomous < human_present < - * human_confirmed): the spec's human-presence gate (e.g. require `human_confirmed` for - * checkout). Enforced at the edge from the verified token; insufficient → 403 weak_auth with - * `required_trust_level`. Unset = any trust level accepted. */ - requireTrustLevel?: TrustLevel; - /** Acceptable `auth.amr` methods (RFC 8176); the AIT must carry at least one (e.g. - * `['face','fpt','hwk']` to require strong human auth). Insufficient → 403 weak_auth with - * `required_amr`. Unset = not enforced. */ - requireAmr?: string[]; - /** Per-issuer compliance policy override, keyed by issuer URL (canonicalized before lookup). - * When a request's AIT is verified and its `iss` matches a key here, that block REPLACES the - * gate's default policy fields (`requireKyc` / `requireSanctionsClear` / `minAge` / - * `allowed/blockedJurisdictions`) for that request: letting a merchant apply different rules - * by issuer (e.g. full compliance for its own AITs, a relaxed set for a partner issuer whose - * tokens carry fewer attested claims). The replacement is whole-policy, not a merge: an issuer - * block of `{ requireKyc: true, minAge: 21 }` evaluates ONLY those two rules for that issuer - * (sanctions / jurisdiction omitted → not enforced for that issuer). Issuers NOT listed here - * use the gate's default policy unchanged. Only the AIT path consults this: wallet / - * operator-token requests are unaffected. - * - * This is a deliberate compliance posture per issuer, not a default; an empty/absent map keeps - * every issuer on the gate's default policy. */ - issuerPolicies?: Record; -} - -/** A per-issuer compliance policy block for {@link AipGateConfig.issuerPolicies}. The same - * compliance fields as the gate, applied (as a whole-policy replacement) only to AITs from the - * matching issuer. */ -interface AipIssuerPolicy { - requireKyc?: boolean; - requireSanctionsClear?: boolean; - minAge?: number; - blockedJurisdictions?: string[]; - allowedJurisdictions?: string[]; -} - /** Surface passed to `Checkout.onSettled` after a payment lands. */ export interface SettleOutcome { /** Protocol family that handled the settle. */ @@ -776,61 +706,6 @@ function pickRail(rails: Record, key: string): T | return spec === undefined ? undefined : (spec as unknown as T); } -/** Resolve a per-issuer compliance-policy override for a verified AIT's issuer. Both the verified - * `iss` and the map keys are canonicalized (lowercase scheme+host, no default port / trailing - * slash) before comparison so `https://issuer.example` and `https://issuer.example/` resolve the same. - * Returns the matching policy block, or undefined when the issuer is not overridden (→ caller - * falls back to the gate's default policy). */ -/** The effective AIP trusted-issuer list: AgentScore's canonical issuer (ALWAYS trusted) plus any - * external issuers. De-duped after canonicalization. Use this for the `agent_memory` hint and any - * presentation surface (llms.txt / mpp.json / skill.md) that advertises AIP acceptance, so a - * merchant relying solely on AgentScore AITs (no external issuers) still advertises the - * `agent_identity` path. Trust enforcement itself lives in {@link JwksCache}, which merges the - * canonical issuer independently. */ -export function buildAipTrustedIssuers(externalIssuers?: string[]): string[] { - const out = [AGENTSCORE_CANONICAL_ISSUER, ...(externalIssuers ?? [])]; - // De-dupe on canonical form so an explicit `https://www.agentscore.com` (or trailing-slash variant) - // doesn't double up; keep the first-seen original string for each canonical key. - const seen = new Set(); - const deduped: string[] = []; - for (const iss of out) { - const key = canonicalizeIssuer(iss) ?? iss; - if (!seen.has(key)) { seen.add(key); deduped.push(iss); } - } - return deduped; -} - -function aipTrustedIssuerSet(cfg: AipGateConfig): string[] { - return buildAipTrustedIssuers(cfg.trustedIssuers); -} - -/** Project the gate's effective compliance policy onto the AIT identity claims it requires, for - * the `required_claims` escalation hint on an `insufficient_claims` AIP denial. Mirrors the - * claim names the API checks an AIT against (`id_verified` / `sanctions_clear` / `age_over_` / - * `jurisdiction`). Empty when the policy is identity-only. */ -function aipRequiredClaims(policy: AipIssuerPolicy): string[] { - const claims: string[] = []; - if (policy.requireKyc) claims.push('id_verified'); - if (policy.requireSanctionsClear) claims.push('sanctions_clear'); - if (policy.minAge != null) claims.push(`age_over_${policy.minAge}`); - if (policy.blockedJurisdictions !== undefined || policy.allowedJurisdictions !== undefined) { - claims.push('jurisdiction'); - } - return claims; -} - -function resolveIssuerPolicy( - issuerPolicies: Record, - iss: string, -): AipIssuerPolicy | undefined { - const target = canonicalizeIssuer(iss); - if (target === null) return undefined; - for (const [key, policy] of Object.entries(issuerPolicies)) { - if (canonicalizeIssuer(key) === target) return policy; - } - return undefined; -} - /** * High-level agentic-commerce orchestrator. * @@ -874,19 +749,6 @@ export class Checkout { readonly discoveryProbe: DiscoveryProbeConfig | undefined; private _x402ServerGetter: (() => Promise) | undefined; - /** Lazily-built JWKS cache for AIP verification, shared across requests so issuer keys - * are fetched once and cached (per the verifier's hard 24h cap). Built on first AIT. */ - private aipJwks: JwksCache | undefined; - - private getAipJwks(cfg: AipGateConfig): JwksCache { - if (this.aipJwks === undefined) { - // JwksCache merges AgentScore's canonical issuer itself, so pass only the merchant's - // external issuers (if any). - this.aipJwks = new JwksCache(cfg.trustedIssuers !== undefined ? { trustedIssuers: cfg.trustedIssuers } : {}); - } - return this.aipJwks; - } - /** * True when the merchant has configured an identity-bearing policy flag: * `require_kyc`, `require_sanctions_clear` (name screening on the KYC @@ -1363,9 +1225,7 @@ export class Checkout { // (apiKey === undefined → runWalletSanctionsOnly) does NOT fire here; // merchants who supply runGate are taking explicit ownership of // compliance enforcement and should call /v1/assess themselves (or - // accept that they're not getting SDN protection). NOTE: `runGate` also - // bypasses the `gate.aip` AIP pre-step below: a custom gate owns AIT - // verification too. `runGate` and `gate.aip` are mutually exclusive. + // accept that they're not getting SDN protection). const result = await gate.runGate(ctx); // Allow merchants to return undefined as an alias for `null` (allow). if (result === undefined || result === null) return null; @@ -1376,108 +1236,12 @@ export class Checkout { } return result; } - // AIP pre-step: runs BEFORE the no-apiKey fallback so a present-but-invalid AIT is always - // a hard deny, and a cryptographically verified AIT is honored even on an offline-only gate. - // The RFC 9421 proof-of-possession can only be checked here at the edge, where the signed - // HTTP message lives. A valid AIT becomes the sole identity (wins over wallet / operator-token). const headers = normalizeHeadersToLowercase(ctx.request.headers); const walletAddress = headers['x-wallet-address']; const operatorToken = headers['x-operator-token']; - let aipToken: string | undefined; - let aipIssuer: string | undefined; - let aipSignature: AgentIdentity['aipSignature']; - if (gate.aip !== undefined && hasAgentIdentityHeaderNode(headers)) { - const aipResult = await verifyAitParts( - { - method: ctx.request.method, - url: ctx.request.url, - headers, - ...(gate.aip.authority !== undefined && { authority: gate.aip.authority }), - }, - { - jwks: this.getAipJwks(gate.aip), - ...(gate.aip.maxSkewSeconds !== undefined && { maxSkewSeconds: gate.aip.maxSkewSeconds }), - }, - ); - if (!aipResult.ok) { - const body = buildAipErrorBody(aipResult.failure, { - trustedIssuers: aipTrustedIssuerSet(gate.aip), - ...(gate.aip.requireTrustLevel !== undefined && { requiredTrustLevel: gate.aip.requireTrustLevel }), - ...(gate.aip.requireAmr !== undefined && { requiredAmr: gate.aip.requireAmr }), - }); - return { - status: body.status, - body: body as unknown as Record, - headers: { - 'content-type': 'application/problem+json', - // 503 = the IdP's JWKS was unreachable (transient infra, not a bad token). Hint a - // short backoff so agents retry rather than uselessly re-signing. - ...(body.status === 503 && { 'retry-after': '5' }), - }, - }; - } - aipToken = aipResult.ait.token; - aipIssuer = aipResult.ait.iss; - aipSignature = aipResult.ait.signatureMaterial; - - // Enforce the merchant's trust_level / auth.amr requirement (the spec's human-presence gate). - // Verification-derived (carried in the verified token), so enforced here at the edge: - // insufficient → weak_auth (403) with required_* so the agent can step up (re-mint a - // higher-trust AIT) rather than guess. - const weakAuthDetail = checkTrustRequirements(aipResult.ait.payload, gate.aip.requireTrustLevel, gate.aip.requireAmr); - if (weakAuthDetail) { - const body = buildAipWeakAuthBody({ - detail: weakAuthDetail, - ...(gate.aip.requireTrustLevel !== undefined && { requiredTrustLevel: gate.aip.requireTrustLevel }), - ...(gate.aip.requireAmr !== undefined && { requiredAmr: gate.aip.requireAmr }), - trustedIssuers: aipTrustedIssuerSet(gate.aip), - }); - return { status: 403, body: body as unknown as Record, headers: { 'content-type': 'application/problem+json' } }; - } - } - - // Resolve the per-issuer policy override (if any) for the verified AIT's issuer. Matched on - // the canonicalized issuer so keys line up with the trust list's canonicalization. When set, - // it REPLACES the gate's default compliance policy for this request. - const issuerPolicy: AipIssuerPolicy | undefined = - aipIssuer !== undefined && gate.aip?.issuerPolicies !== undefined - ? resolveIssuerPolicy(gate.aip.issuerPolicies, aipIssuer) - : undefined; - // The effective compliance fields for this request: the issuer override when present, else - // the gate defaults. Used by both the no-apiKey policy-presence check and the coreOpts build. - const effPolicy: AipIssuerPolicy = issuerPolicy ?? { - ...(gate.requireKyc !== undefined && { requireKyc: gate.requireKyc }), - ...(gate.requireSanctionsClear !== undefined && { requireSanctionsClear: gate.requireSanctionsClear }), - ...(gate.minAge !== undefined && { minAge: gate.minAge }), - ...(gate.blockedJurisdictions !== undefined && { blockedJurisdictions: gate.blockedJurisdictions }), - ...(gate.allowedJurisdictions !== undefined && { allowedJurisdictions: gate.allowedJurisdictions }), - }; if (gate.apiKey === undefined) { - if (aipToken !== undefined) { - // A cryptographically verified AIT is a complete offline *identity* check (issuer - // signature + RFC 9421 PoP). But compliance *policy* (KYC / age / sanctions / - // jurisdiction) is evaluated against the token's claims by `/v1/assess`, which needs an - // apiKey. If the merchant declared policy fields without an apiKey we cannot enforce - // them: fail closed rather than silently allow a verified-but-non-compliant identity - // (e.g. an under-21 AIT through a `minAge: 21` gate). Identity-only gates (no policy - // fields) are satisfied by the verified AIT alone. - const hasPolicy = effPolicy.requireKyc || effPolicy.requireSanctionsClear || effPolicy.minAge != null - || effPolicy.blockedJurisdictions !== undefined || effPolicy.allowedJurisdictions !== undefined; - if (hasPolicy) { - return { - status: 403, - body: { - error: { - code: 'aip_policy_requires_api_key', - message: 'This gate declares compliance policy (KYC / age / sanctions / jurisdiction) but has no AgentScore apiKey, so the Agent Identity Token’s claims cannot be evaluated. Configure gate.apiKey to enable policy enforcement on AITs.', - }, - }, - }; - } - return null; - } - // No AIT, no apiKey → fall through to wallet OFAC SDN enforcement (the strict-liability + // No apiKey → fall through to wallet OFAC SDN enforcement (the strict-liability // default) so the merchant still gets the basic protection layer instead of allowing. return this.runWalletSanctionsOnly(ctx); } @@ -1498,21 +1262,14 @@ export class Checkout { apiKey: gate.apiKey, ...(gate.baseUrl !== undefined && { baseUrl: gate.baseUrl }), ...(gate.userAgent !== undefined && { userAgent: gate.userAgent }), - // Compliance fields come from effPolicy: the per-issuer override for the verified AIT's - // issuer when one is configured, else the gate defaults (see effPolicy above). A whole- - // policy replacement: an issuer override of `{ requireKyc, minAge }` sends ONLY those, - // so sanctions / jurisdiction are not enforced for that issuer. - ...(effPolicy.requireKyc !== undefined && { requireKyc: effPolicy.requireKyc }), - ...(effPolicy.requireSanctionsClear !== undefined && { requireSanctionsClear: effPolicy.requireSanctionsClear }), - ...(effPolicy.minAge !== undefined && { minAge: effPolicy.minAge }), - ...(effPolicy.blockedJurisdictions !== undefined && { blockedJurisdictions: effPolicy.blockedJurisdictions }), - ...(effPolicy.allowedJurisdictions !== undefined && { allowedJurisdictions: effPolicy.allowedJurisdictions }), + ...(gate.requireKyc !== undefined && { requireKyc: gate.requireKyc }), + ...(gate.requireSanctionsClear !== undefined && { requireSanctionsClear: gate.requireSanctionsClear }), + ...(gate.minAge !== undefined && { minAge: gate.minAge }), + ...(gate.blockedJurisdictions !== undefined && { blockedJurisdictions: gate.blockedJurisdictions }), + ...(gate.allowedJurisdictions !== undefined && { allowedJurisdictions: gate.allowedJurisdictions }), ...(gate.failOpen !== undefined && { failOpen: gate.failOpen }), ...(gate.cacheSeconds !== undefined && { cacheSeconds: gate.cacheSeconds }), ...(gate.chain !== undefined && { chain: gate.chain }), - // Surface AIP acceptance in the missing-identity recovery instructions + agent_memory - // hint so agents holding an AIT learn they can present it instead of bootstrapping. - ...(gate.aip !== undefined && { aipTrustedIssuers: aipTrustedIssuerSet(gate.aip) }), // Auto-default `createSessionOnMissing` from gate config when the merchant // didn't supply one, so every gated route gets the bootstrap session-mint UX // out of the box. Merchants who need custom session context or onBeforeSession @@ -1528,17 +1285,13 @@ export class Checkout { const core = createAgentScoreCore(coreOpts); - // headers / walletAddress / operatorToken / aipToken were resolved in the AIP pre-step - // above (which runs before the no-apiKey fallback). AIT wins when present; else wallet/operator. const identity: AgentIdentity | undefined = - aipToken !== undefined - ? { aipToken, ...(aipSignature !== undefined && { aipSignature }) } - : walletAddress !== undefined || operatorToken !== undefined - ? { - ...(walletAddress !== undefined && { address: walletAddress }), - ...(operatorToken !== undefined && { operatorToken }), - } - : undefined; + walletAddress !== undefined || operatorToken !== undefined + ? { + ...(walletAddress !== undefined && { address: walletAddress }), + ...(operatorToken !== undefined && { operatorToken }), + } + : undefined; const x402Header = headers['payment-signature'] ?? headers['x-payment']; const signer = await extractPaymentSignerFromAuth(headers['authorization'], x402Header); const outcome: EvaluateOutcome = await core.evaluate(identity, ctx, signer); @@ -1566,12 +1319,8 @@ export class Checkout { // the assess call when a signer was extracted; convert non-pass verdicts // into wallet_signer_mismatch / wallet_auth_requires_wallet_signing // denials so the gate enforces wallet binding inline (no separate hook). - // Signer-match enforcement applies only to the wallet identity path. On the AIT path the - // identity is the token (PoP-bound via cnf) and assess was keyed by aip_token, so there is - // no address-keyed signer verdict to read: the wallet binding for AITs is the IdP's - // `payment.signer` claim, enforced server-side. Guarding on aipToken===undefined keeps - // this from being dead code that silently no-ops on a cache miss. - if (aipToken === undefined && walletAddress !== undefined) { + // Signer-match enforcement applies only to the wallet identity path. + if (walletAddress !== undefined) { const sm = outcome.signerVerdict?.signer_match; if (sm && sm.kind !== 'pass') { const reason: DenialReason = sm.kind === 'wallet_auth_requires_wallet_signing' @@ -1606,25 +1355,6 @@ export class Checkout { if (custom !== null) return custom; } const body = denialReasonToBody(reason); - // AIT-input denial (a verified AIT that /v1/assess then denied): emit the AgentScore body as - // an RFC 9457 + AIP-spec SUPERSET so the response is both schemes at once: the rich - // AgentScore `{ error, agent_instructions, ... }` AND the spec's `type`/`title`/`status`/ - // `detail` (+ escalation). `application/problem+json` so spec consumers content-negotiate it. - // The wallet / operator-token paths (aipToken undefined) keep the bare AgentScore body + - // application/json (the renderers' default), untouched. - if (aipToken !== undefined) { - const superset = buildAipPolicyDenyBody(reason.code, reason.reasons, body, { - trustedIssuers: gate.aip !== undefined ? aipTrustedIssuerSet(gate.aip) : undefined, - requiredClaims: aipRequiredClaims(effPolicy), - ...(gate.aip?.requireTrustLevel !== undefined && { requiredTrustLevel: gate.aip.requireTrustLevel }), - ...(gate.aip?.requireAmr !== undefined && { requiredAmr: gate.aip.requireAmr }), - }); - return { - status: superset.status as number, - body: superset, - headers: { 'content-type': 'application/problem+json' }, - }; - } const status = reason.code === 'token_expired' || reason.code === 'invalid_credential' ? 401 @@ -2160,13 +1890,8 @@ export class Checkout { retryBody: ctx.request.body, // Merchants without an identity-bearing gate get a clean 402: no // AgentScore-identity bootstrap describing a verification flow they - // don't run. Wallet OFAC (the always-on default) doesn't need it. When the - // merchant accepts AIP, advertise the agent_identity path too (AgentScore's - // own issuer is always trusted, so this fires even with no external issuers). - agentMemory: firstEncounterAgentMemory({ - firstEncounter: this.hasIdentityGate(), - ...(this.gate?.aip !== undefined && { aipTrustedIssuers: aipTrustedIssuerSet(this.gate.aip) }), - }), + // don't run. Wallet OFAC (the always-on default) doesn't need it. + agentMemory: firstEncounterAgentMemory({ firstEncounter: this.hasIdentityGate() }), ...(ctx.pricing.product ? { product: ctx.pricing.product as { id: string; name: string } } : {}), ...(ctx.pricing.bodyExtras || identityBootstrap ? { extra: { ...(identityBootstrap && { identity_bootstrap: identityBootstrap }), ...ctx.pricing.bodyExtras } } @@ -2341,10 +2066,9 @@ function stripContentType(headers: Record): Record): string | undefined { for (const [k, v] of Object.entries(headers)) { @@ -2458,9 +2182,9 @@ declare module './checkout' { raw: req, }); for (const [k, v] of Object.entries(stripContentType(result.headers))) res.setHeader(k, v); - // Honor an explicit content-type (AIP problem+json): setting it before json() makes Express - // respect it instead of forcing application/json. Only the AIP path sets one, so non-AIP - // responses are untouched (res.json defaults to application/json as before). + // Honor an explicit content-type: setting it before json() makes Express respect it instead + // of forcing application/json. Responses without one are untouched (res.json defaults to + // application/json). const ct = explicitContentType(result.headers); if (ct !== undefined) res.setHeader('Content-Type', ct); res.status(result.status); @@ -2505,10 +2229,10 @@ declare module './checkout' { }); for (const [k, v] of Object.entries(stripContentType(result.headers))) reply.header(k, v); reply.code(result.status); - // Honor an explicit content-type (AIP problem+json): set it + send a pre-serialized string so - // Fastify emits the body verbatim under the chosen content-type instead of forcing - // application/json via its object serializer. The string is valid JSON, so parsers still read it - // back. Non-AIP responses keep the object path (Fastify serializes + sets application/json). + // Honor an explicit content-type: set it + send a pre-serialized string so Fastify emits the + // body verbatim under the chosen content-type instead of forcing application/json via its + // object serializer. The string is valid JSON, so parsers still read it back. Responses + // without one keep the object path (Fastify serializes + sets application/json). const ct = explicitContentType(result.headers); if (ct !== undefined) { reply.header('content-type', ct); diff --git a/src/core.ts b/src/core.ts index 4177939..9b4cb32 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,7 +1,5 @@ -import { createHash } from 'node:crypto'; import { AgentScore, - type AipSignatureMaterial, InvalidCredentialError, PaymentRequiredError, QuotaExceededError, @@ -31,15 +29,6 @@ declare const __VERSION__: string; export interface AgentIdentity { address?: string; operatorToken?: string; - /** Raw AIP Agent Identity Token (a JWT). When set, the gate has verified the token's RFC 9421 - * proof-of-possession at the edge as a fail-fast filter; `evaluate` forwards it to `/v1/assess` - * as `aip_token` (with {@link aipSignature}) for AUTHORITATIVE server-side re-verification of the - * IdP signature + proof-of-possession + policy. */ - aipToken?: string; - /** RFC 9421 signature material accompanying {@link aipToken}, forwarded to `/v1/assess` as - * `aip_signature` so the API re-verifies proof-of-possession itself (the edge is not trusted as - * the authority). Always set together with `aipToken`. */ - aipSignature?: AipSignatureMaterial; } /** @@ -117,11 +106,6 @@ export interface AgentScoreCoreOptions { userAgent?: string; /** When set and no identity is found, create a verification session instead of denying immediately. */ createSessionOnMissing?: CreateSessionOnMissing; - /** Issuers whose AIP Agent Identity Tokens this gate accepts. When set, the missing-identity - * recovery instructions and the `agent_memory` hint advertise the AIT path so agents holding - * one can present it. Set by Checkout from `gate.aip.trustedIssuers`; the actual AIT - * verification happens at the edge (Checkout) before `evaluate`. */ - aipTrustedIssuers?: string[]; } export type DenialCode = @@ -154,11 +138,7 @@ export interface AgentMemoryHint { quickstart: string; identity_check_endpoint: string; list_wallets_endpoint?: string; - identity_paths: { wallet: string; operator_token: string; agent_identity?: string }; - /** Issuers whose AIP Agent Identity Tokens this merchant accepts. Present only when the - * merchant opted into AIP; an agent holding an AIT from one of these can present it via an - * `Agent-Identity` header + RFC 9421 signature instead of bootstrapping a fresh credential. */ - aip_trusted_issuers?: string[]; + identity_paths: { wallet: string; operator_token: string }; bootstrap: string; do_not_persist_in_memory: string[]; persist_in_credential_store: string[]; @@ -251,16 +231,6 @@ export interface AssessResult { operator_handle?: string; verify_url?: string; policy_result?: PolicyResult | null; - /** IdP provenance, present only when `identity_method === 'aip_token'`: which issuer attested - * the identity and the trust level it asserted. Mirrors the SDK's `AssessResponse.aip`. */ - aip?: { - issuer: string; - subject: string; - trust_level?: 'autonomous' | 'human_present' | 'human_confirmed'; - agent_provider?: string; - /** True when /v1/assess re-verified the RFC 9421 proof-of-possession (always true on success). */ - pop_verified?: boolean; - }; } /** @@ -300,7 +270,7 @@ export interface GateQuotaInfo { * core) so concurrent same-wallet/different-signer requests can't read each other's verdict: * the adapter stashes it on its per-request state for `getSignerVerdict(ctx)` to read back. * Present only on wallet-only identity when the response carried either signer block; `undefined` - * otherwise (operator-token / AIT paths, discovery legs with no signer). + * otherwise (operator-token paths, discovery legs with no signer). * - `{ kind: 'deny', reason }`: the request was denied. Adapters should render a 403 with the * reason, or invoke the caller's custom denial handler. */ @@ -466,12 +436,11 @@ const INVALID_CREDENTIAL_INSTRUCTIONS = JSON.stringify({ 'The operator_token is not recognized. Use a different stored token, or restart the verification session flow to mint a new one.', }); -export function buildAgentMemoryHint(aipTrustedIssuers?: string[]): AgentMemoryHint { +export function buildAgentMemoryHint(): AgentMemoryHint { // Memory pointers always point at the canonical production API: merchants must not be // able to redirect the cross-merchant memory surface, since that would be a phishing // vector for the verify URL. const api = CANONICAL_AGENTSCORE_API; - const acceptsAip = aipTrustedIssuers !== undefined && aipTrustedIssuers.length > 0; return { save_for_future_agentscore_gates: true, pattern_summary: @@ -489,16 +458,7 @@ export function buildAgentMemoryHint(aipTrustedIssuers?: string[]): AgentMemoryH operator_token: 'For any rail (Stripe SPT, card, or a wallet you have not linked yet), send ' + '`X-Operator-Token: opc_...`. Reusable across merchants until the token expires.', - ...(acceptsAip && { - agent_identity: - 'If you hold an AIP Agent Identity Token from a trusted issuer (see aip_trusted_issuers), ' + - 'present it: send the JWT in an `Agent-Identity` header plus an RFC 9421 HTTP Message ' + - 'Signature (Signature-Input + Signature over @method @authority @path agent-identity, ' + - 'tag="agent-identity") signed with the token-bound cnf key. The merchant verifies it and ' + - 'applies its policy: no separate AgentScore credential needed.', - }), }, - ...(acceptsAip && { aip_trusted_issuers: aipTrustedIssuers }), bootstrap: 'If you have neither a linked wallet nor a valid operator_token, follow the session/verify ' + 'flow in the per-request `agent_instructions` block. This happens at most once per agent ' + @@ -531,11 +491,10 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore chain: gateChain, userAgent, createSessionOnMissing, - aipTrustedIssuers, } = options; const baseUrl = stripTrailingSlashes(rawBaseUrl); - const agentMemoryHint = buildAgentMemoryHint(aipTrustedIssuers); + const agentMemoryHint = buildAgentMemoryHint(); const defaultUa = `@agent-score/commerce@${__VERSION__}`; const userAgentHeader = userAgent ? `${userAgent} (${defaultUa})` : defaultUa; @@ -668,7 +627,7 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore ): Promise { // Treat "returned identity object with no usable fields" the same as "no identity at all": // otherwise a misbehaving custom extractIdentity would send an empty body to /v1/assess. - if (!identity || (!identity.address?.trim() && !identity.operatorToken?.trim() && !identity.aipToken?.trim())) { + if (!identity || (!identity.address?.trim() && !identity.operatorToken?.trim())) { // failOpen short-circuits BEFORE the session mint. This branch isn't an infra failure // (no AgentScore call has been made yet) so we don't mark the gate state as degraded: // missing identity + failOpen is the explicit opt-in pass-through behavior, not a @@ -684,16 +643,9 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore // strategy so agents without memory can recover: try wallet first on signing rails, // fall back to stored opc_..., fall back to session flow (noting that only // auto-session merchants return verify_url + poll data on the retry). - const acceptsAip = aipTrustedIssuers !== undefined && aipTrustedIssuers.length > 0; - const aipStep = acceptsAip - ? [ - `If you hold an AIP Agent Identity Token from a trusted issuer (${aipTrustedIssuers.join(', ')}): present it: send the JWT in an Agent-Identity header plus an RFC 9421 HTTP Message Signature (Signature-Input + Signature over @method @authority @path agent-identity, tag="agent-identity") signed with the token-bound cnf key. This satisfies identity in one round trip without an AgentScore credential.`, - ] - : []; const missingIdentityInstructions = JSON.stringify({ action: 'probe_identity_then_session', steps: [ - ...aipStep, 'If you have a wallet AND this merchant accepts a wallet-signing rail (Tempo MPP or x402; see openapi.json x-payment-info): send X-Wallet-Address and retry. If the wallet is already linked to an AgentScore operator (via prior SIWE claim or prior captureWallet), this passes in one round trip. If the wallet is unlinked or the account has no KYC, the 403 will include a verify_url: share it with the user to claim the wallet + complete KYC, then retry.', 'If step 1 is denied or you already have a stored operator_token (valid + not expired): send X-Operator-Token: opc_... and retry.', 'If neither applies: retry with NO identity header. Merchants that auto-create verification sessions (most AgentScore merchants do) return verify_url + session_id + poll_secret in the 403 body: share verify_url with the user, then poll poll_url every 5s with the X-Poll-Secret header until status=verified (the poll returns a one-time operator_token). If the retry returns the same bare 403, this merchant does not support self-service session bootstrapping: direct the user to https://www.agentscore.com/sign-up to create an AgentScore identity and mint an operator_token from their dashboard (https://www.agentscore.com/dashboard/verify). The user hands the opc_... to you, and you retry with X-Operator-Token.', @@ -715,12 +667,7 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore // through normalizeAddress because Solana base58 is case-sensitive and lowercasing // would corrupt the cache key (a Solana cache miss every time, plus collision risk // with mixed-case variants of the same operator). - // AIT cache key: hash the raw token so the (short-lived) JWT isn't held verbatim as a - // Map key. AITs are seconds-to-minutes TTL anyway; this just dedupes repeated presents - // within the cache window. Falls through to operator_token / address otherwise. - const identityKey = identity.aipToken - ? `aip:${createHash('sha256').update(identity.aipToken).digest('hex')}` - : identity.operatorToken?.toLowerCase() ?? (identity.address ? normalizeAddress(identity.address) : ''); + const identityKey = identity.operatorToken?.toLowerCase() ?? (identity.address ? normalizeAddress(identity.address) : ''); // The assess response carries per-request, signer-derived verdicts (signer_match + // signer_sanctions). The signer is NOT part of the identity, so it must be folded into the @@ -812,19 +759,9 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore ...(signer && { signer: { address: signer.address, network: signer.network } }), }; // SDK has overloads: narrow by which identity is set so TS picks the right one. - // AIT takes precedence: when present it's the sole identity input. The edge already - // PoP-verified as a fail-fast filter; we forward the token + signature material so the API - // re-verifies the IdP signature AND the proof-of-possession authoritatively. Checkout always - // sets both together; a hand-rolled evaluate() with aipToken but no aipSignature is a caller - // error: fail loudly rather than silently misrouting to the operator-token branch. - if (identity.aipToken !== undefined && identity.aipSignature === undefined) { - throw new Error('AgentScoreCore.evaluate: aipToken requires aipSignature (RFC 9421 proof-of-possession material).'); - } - const result = identity.aipToken !== undefined && identity.aipSignature !== undefined - ? await sdk.assess(null, { ...opts, aipToken: identity.aipToken, aipSignature: identity.aipSignature }) - : identity.address - ? await sdk.assess(identity.address, { ...opts, operatorToken: identity.operatorToken }) - : await sdk.assess(null, { ...opts, operatorToken: identity.operatorToken! }); + const result = identity.address + ? await sdk.assess(identity.address, { ...opts, operatorToken: identity.operatorToken }) + : await sdk.assess(null, { ...opts, operatorToken: identity.operatorToken! }); data = result as unknown as Record; } catch (err) { if (err instanceof PaymentRequiredError) { @@ -1082,8 +1019,8 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore /** * Build the per-request signer verdicts (signer_match + signer_sanctions) from an assess - * response. Returns `undefined` unless the wallet is the EFFECTIVE identity (no operator_token, - * no AIT: operator-token / AIT wins and signer-match is deliberately NOT enforced) AND the + * response. Returns `undefined` unless the wallet is the EFFECTIVE identity (no operator_token: + * operator-token wins and signer-match is deliberately NOT enforced) AND the * response carried at least one signer block. * * The verdict is returned on the `EvaluateOutcome` and stashed by each adapter on its @@ -1096,8 +1033,7 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore ): SignerVerdict | undefined { if ( identity.address === undefined || - identity.operatorToken !== undefined || - identity.aipToken !== undefined + identity.operatorToken !== undefined ) { return undefined; } diff --git a/src/discovery/llms_txt.ts b/src/discovery/llms_txt.ts index 54ad005..d8cfa06 100644 --- a/src/discovery/llms_txt.ts +++ b/src/discovery/llms_txt.ts @@ -6,14 +6,9 @@ export function llmsTxtIdentitySection({ agentscore, compliance, - aip, }: { /** When true, include the AgentScore identity-paths explanation (wallet vs operator-token). */ agentscore?: boolean; - /** When true, also advertise the AIP Agent Identity Token path. AgentScore's own issuer is - * always trusted, so set this whenever the merchant has an AIP gate (even with no external - * issuers). */ - aip?: boolean; /** Compliance policy to mention (KYC, age, jurisdiction). */ compliance?: { require_kyc?: boolean; @@ -25,13 +20,6 @@ export function llmsTxtIdentitySection({ if (!agentscore) { return ''; } - const aipBullet = aip - ? '\n- **`Agent-Identity: ` + RFC 9421 signature**: present an Agent Identity Token (AIP) ' + - 'from a trusted issuer (AgentScore is always trusted). Short-lived and bound to your key; sign ' + - 'the request (`Signature-Input` + `Signature` over `@method @authority @path agent-identity`, ' + - 'tag `agent-identity`) to prove possession. No long-lived token on the wire. Mint one with ' + - '`agentscore-pay identity-mint` or let `agentscore-pay pay --identity aip` attach it automatically.' - : ''; const complianceNote = compliance ? `\n\nCompliance: ${[ compliance.require_kyc ? 'KYC required' : null, @@ -49,7 +37,7 @@ export function llmsTxtIdentitySection({ AgentScore identity is reusable across every AgentScore-gated merchant: one KYC, no re-verification per site. Pick a header: - **\`X-Wallet-Address: 0x...\` or base58**: works on signing rails (Tempo, x402, Solana MPP). The wallet you claim must sign the payment. -- **\`X-Operator-Token: opc_...\`**: works on every rail, including Stripe SPT. Reusable across AgentScore merchants until expiry.${aipBullet} +- **\`X-Operator-Token: opc_...\`**: works on every rail, including Stripe SPT. Reusable across AgentScore merchants until expiry. - **Neither**: send the purchase request with \`X-Verification-Session: create\` and no payment credential, and the 403 carries \`verify_url\`. Complete the session flow once and reuse the resulting \`opc_...\` everywhere.${complianceNote}`; } diff --git a/src/discovery/openapi.ts b/src/discovery/openapi.ts index f0b8261..fddd4b3 100644 --- a/src/discovery/openapi.ts +++ b/src/discovery/openapi.ts @@ -15,17 +15,8 @@ import { usdToAtomic } from '../payment/amounts'; * Includes `siwx` (Sign-In With X) per the x402scan discovery spec so identity-gated * operations can declare `security: [{ siwx: [] }]` and stay classified as identity-only, * not paid. - * - * When `aip` is true, also advertise the AIP Agent Identity Token scheme. AgentScore's own - * issuer is always trusted, so set this whenever the merchant has an AIP gate (even with no - * external issuers). */ -export function agentscoreSecuritySchemes({ - aip = false, -}: { - /** Advertise the AIP Agent Identity Token scheme. Default false. */ - aip?: boolean; -} = {}): Record { +export function agentscoreSecuritySchemes(): Record { const schemes: Record = { OperatorToken: { type: 'apiKey', @@ -42,15 +33,6 @@ export function agentscoreSecuritySchemes({ 'Wallet-path identity (0x... or base58). Only works on rails that carry a wallet signature (Tempo MPP, x402 EIP-3009 on Base, Solana MPP). The wallet you claim MUST sign the payment.', }, }; - if (aip) { - schemes.AgentIdentity = { - type: 'apiKey', - in: 'header', - name: 'Agent-Identity', - description: - 'AIP Agent Identity Token path (a JWT from a trusted issuer; AgentScore is always trusted). Opt-in. The token is bound to the agent key via `cnf`; the request MUST also carry an RFC 9421 HTTP Message Signature (`Signature-Input` + `Signature` over `@method @authority @path agent-identity`, tag `agent-identity`) proving possession. A verified AIT is the sole identity and is evaluated against the merchant policy via its attested claims.', - }; - } schemes.siwx = siwxSecurityScheme(); return schemes; } @@ -425,7 +407,6 @@ export function agentscoreOpenApiSnippets({ security = true, denials = true, paymentRequired = true, - aip = false, }: { /** Include security schemes in the snippet. Default true. */ security?: boolean; @@ -433,12 +414,10 @@ export function agentscoreOpenApiSnippets({ denials?: boolean; /** Include the 402 PaymentRequired schema in the snippet. Default true. */ paymentRequired?: boolean; - /** Advertise the AIP Agent Identity Token scheme. Default false. */ - aip?: boolean; } = {}): { securitySchemes?: Record; schemas?: Record } { const out: { securitySchemes?: Record; schemas?: Record } = {}; if (security) { - out.securitySchemes = agentscoreSecuritySchemes({ aip }); + out.securitySchemes = agentscoreSecuritySchemes(); } if (denials || paymentRequired) { out.schemas = { diff --git a/src/discovery/skill_md.ts b/src/discovery/skill_md.ts index b64b9ed..15e66d2 100644 --- a/src/discovery/skill_md.ts +++ b/src/discovery/skill_md.ts @@ -20,9 +20,6 @@ export interface SkillMdIdentityRequirements { allowedJurisdictions?: string[]; /** Whether sanctions screening is enforced. */ sanctionsClear?: boolean; - /** Whether the merchant accepts AIP Agent Identity Tokens (AgentScore is always a trusted - * issuer). When true, the Identity section documents the `Agent-Identity` + RFC 9421 path. */ - aip?: boolean; } /** PHYSICAL-GOODS-ONLY. Shipping-policy block for skill.md. Digital goods and @@ -242,24 +239,15 @@ function identitySection(input: BuildSkillMdInput): string { if (id.minAge) reqs.push(`age ${id.minAge}+`); if (id.allowedJurisdictions?.length) reqs.push(`${id.allowedJurisdictions.join('/')} only`); if (id.sanctionsClear) reqs.push('sanctions clear'); - if (reqs.length === 0 && !id.aip) return ''; + if (reqs.length === 0) return ''; const bootstrap = input.identityBootstrapUrl ? `\n\nIf you don't have a Passport, fetch \`${input.identityBootstrapUrl}\` and follow the onboarding there first. Bring back the \`opc_...\` operator token in \`X-Operator-Token\` on every gated request.` : ''; - const reqLine = reqs.length - ? `This merchant uses AgentScore identity. Required: ${reqs.join(', ')}.${bootstrap}` - : `This merchant uses AgentScore identity.${bootstrap}`; - const aipNote = id.aip - ? '\n\nThis merchant accepts AIP Agent Identity Tokens. If you hold an AIT from a trusted issuer ' + - '(AgentScore is always trusted), present the JWT in an `Agent-Identity` header plus an RFC 9421 ' + - 'HTTP Message Signature (`Signature-Input` + `Signature` over `@method @authority @path agent-identity`, ' + - 'tag `agent-identity`) signed with the token-bound cnf key, satisfying identity in one round trip, ' + - 'no separate AgentScore credential needed. `agentscore-pay pay --identity aip` does this automatically.' - : ''; + const reqLine = `This merchant uses AgentScore identity. Required: ${reqs.join(', ')}.${bootstrap}`; return [ '## Identity Prerequisite', '', - `${reqLine}${aipNote}`, + reqLine, '', 'Denial bodies carry an `agent_instructions` block describing the recovery action: read the `action` field and follow it. See the identity-bootstrap skill for the canonical denial-code → action table.', ].join('\n'); diff --git a/src/discovery/well_known_mpp.ts b/src/discovery/well_known_mpp.ts index 52683a9..855b166 100644 --- a/src/discovery/well_known_mpp.ts +++ b/src/discovery/well_known_mpp.ts @@ -15,9 +15,6 @@ export interface PaymentMethodConfig { identity_paths?: { wallet?: { header: string; applies_to_rails: string[]; note?: string }; operator_token?: { header: string; applies_to_rails: string[]; note?: string }; - /** AIP Agent Identity Token path (opt-in merchants). `header` is `Agent-Identity`; the request - * also carries the RFC 9421 `Signature-Input`/`Signature` proof-of-possession. */ - agent_identity?: { header: string; applies_to_rails: string[]; note?: string; trusted_issuers?: string[] }; }; /** Compliance policy summary for agents to know what they need before purchasing. */ compliance?: { diff --git a/src/identity/a2a.ts b/src/identity/a2a.ts index 7f6734e..a1d8bdb 100644 --- a/src/identity/a2a.ts +++ b/src/identity/a2a.ts @@ -26,10 +26,6 @@ const DEFAULT_OUTPUT_MODE = 'application/json'; * to the 2026-08-25 spec snapshot. */ export const UCP_A2A_EXTENSION_URI = 'https://ucp.dev/2026-08-25/specification/reference'; -/** Canonical URI for the AIP (Agentic Identity Protocol) A2A agent-card extension: points at the - * issuer-discovery well-known so a reader can resolve the protocol. */ -export const AIP_A2A_EXTENSION_URI = 'https://www.agentscore.com/.well-known/agent-identity'; - /** One transport+URL combination beyond the primary `url`, passed as `additionalInterfaces`; * each becomes a `supportedInterfaces` entry after the primary one. */ export interface A2AAgentInterface { @@ -95,35 +91,6 @@ export function ucpA2AExtension( }; } -/** Build the AIP entry for an A2A agent card's `capabilities.extensions[]`. Advertises that the - * agent accepts an Agent Identity Token (JWT) in an `Agent-Identity` header plus an RFC 9421 - * proof-of-possession signature: so an agent discovering via the agent-card learns it can present - * an AIT (matching what the merchant's mpp.json / llms.txt / skill.md already advertise). Optional - * `requiredTrustLevel` / `requiredAmr` surface a gate's human-presence requirement. */ -export function aipA2AExtension( - opts: { - trustedIssuers?: string[]; - requiredTrustLevel?: 'autonomous' | 'human_present' | 'human_confirmed'; - requiredAmr?: string[]; - required?: boolean; - } = {}, -): A2AAgentCardExtension { - return { - uri: AIP_A2A_EXTENSION_URI, - description: - 'AIP support: present an Agent Identity Token (JWT) in an Agent-Identity header plus an RFC 9421 ' + - 'HTTP Message Signature (over @method @authority @path agent-identity) proving possession of the ' + - 'token-bound cnf key. `agentscore-pay pay --identity aip` attaches it automatically.', - required: opts.required ?? false, - params: { - header: 'Agent-Identity', - signature: 'RFC 9421', - ...(opts.trustedIssuers !== undefined && opts.trustedIssuers.length > 0 && { trusted_issuers: opts.trustedIssuers }), - ...(opts.requiredTrustLevel !== undefined && { required_trust_level: opts.requiredTrustLevel }), - ...(opts.requiredAmr !== undefined && opts.requiredAmr.length > 0 && { required_amr: opts.requiredAmr }), - }, - }; -} /** Optional capabilities the agent supports (A2A 1.0 `AgentCapabilities`). */ export interface A2AAgentCardCapabilities { diff --git a/src/identity/express.ts b/src/identity/express.ts index f6c7850..85073a0 100644 --- a/src/identity/express.ts +++ b/src/identity/express.ts @@ -1,11 +1,8 @@ import { denialReasonStatus } from '../_denial'; import { denialReasonToBody } from '../_response'; -import { buildAipErrorBody, evaluateAipParts, type AipGateOptions } from '../aip/gate'; -import { hasAgentIdentityHeaderNode } from '../aip/request'; import { createAgentScoreCore } from '../core'; import { shouldRunConditionalGate } from '../payment/payment_header'; import { extractPaymentSignerFromAuth } from '../signer'; -import type { VerifiedAit } from '../aip/verify'; import type { AgentIdentity, AgentScoreCore, @@ -212,70 +209,6 @@ export function conditionalAgentscoreGate(options: AgentScoreGateOptions) { }; } -// --------------------------------------------------------------------------- -// AIP gate (Agentic Identity Protocol): verifies a key-bound Agent Identity Token (AIT) -// from a trusted IdP instead of an opaque operator token. Cryptographic identity only; -// merchants who want compliance enrichment feed the verified claims to /v1/assess. -// --------------------------------------------------------------------------- - -const AIT_STATE_KEY = '__agentscoreAit'; - -export interface AipGateExpressOptions extends AipGateOptions { - /** Custom denial responder. Defaults to a 401/403 `application/problem+json` body. */ - onDenied?: (req: Request, res: Response, body: ReturnType) => void; -} - -function defaultAipOnDenied(_req: Request, res: Response, body: ReturnType): void { - res.status(body.status).type('application/problem+json').json(body); -} - -/** - * Express middleware that requires a valid AIT on every request it guards. - * - * ```ts - * import { JwksCache } from '@agent-score/commerce'; - * import { aipGate, getVerifiedAit } from '@agent-score/commerce/identity/express'; - * - * const jwks = new JwksCache({ trustedIssuers: ['https://issuer.example'] }); // AgentScore always trusted - * app.post('/checkout', aipGate({ jwks }), (req, res) => { - * const ait = getVerifiedAit(req)!; - * res.json({ buyer: ait.payload.identity?.email }); - * }); - * ``` - */ -export function aipGate(options: AipGateExpressOptions) { - const { onDenied = defaultAipOnDenied, ...gateOpts } = options; - return async function aipGateMiddleware(req: Request, res: Response, next: NextFunction): Promise { - const result = await evaluateAipParts( - { method: req.method, url: req.url, headers: req.headers as Record }, - gateOpts, - ); - if (!result.ok) { - onDenied(req, res, result.body); - return; - } - (req as unknown as Record)[AIT_STATE_KEY] = result.ait; - next(); - }; -} - -/** Wrap {@link aipGate} so it only runs when an `Agent-Identity` header is present. */ -export function conditionalAipGate(options: AipGateExpressOptions) { - const gate = aipGate(options); - return async function conditionalAipMiddleware(req: Request, res: Response, next: NextFunction): Promise { - if (!hasAgentIdentityHeaderNode(req.headers as Record)) { - next(); - return; - } - return gate(req, res, next); - }; -} - -/** Read the verified AIT attached to an Express request by {@link aipGate}. */ -export function getVerifiedAit(req: Request): VerifiedAit | undefined { - return (req as unknown as Record)[AIT_STATE_KEY]; -} - /** * Read the stable pairwise {@link OperatorHandle} for the ACCOUNT behind this request's * operator token. This is what durable merchant state (prepaid balances first) should key @@ -285,8 +218,8 @@ export function getVerifiedAit(req: Request): VerifiedAit | undefined { * Synchronous and free: the handle rides the gate's existing `/v1/assess` call, so reading * it costs no extra round trip and nothing extra against the merchant's quota. * - * Returns `undefined` when the gate did not run, no operator token was presented (wallet or - * AIT paths), or the API has no handle salt configured. Available on denied requests too, + * Returns `undefined` when the gate did not run, no operator token was presented (wallet + * paths), or the API has no handle salt configured. Available on denied requests too, * so a merchant recording a denial against a buyer can still key it. */ export function getOperatorHandle(req: Request): OperatorHandle | undefined { diff --git a/src/identity/fastify.ts b/src/identity/fastify.ts index 4c5a205..a42c506 100644 --- a/src/identity/fastify.ts +++ b/src/identity/fastify.ts @@ -1,11 +1,8 @@ import { denialReasonStatus } from '../_denial'; import { denialReasonToBody } from '../_response'; -import { buildAipErrorBody, evaluateAipParts, type AipGateOptions } from '../aip/gate'; -import { hasAgentIdentityHeaderNode } from '../aip/request'; import { createAgentScoreCore } from '../core'; import { shouldRunConditionalGate } from '../payment/payment_header'; import { extractPaymentSignerFromAuth } from '../signer'; -import type { VerifiedAit } from '../aip/verify'; import type { AgentIdentity, AgentScoreCore, @@ -261,75 +258,6 @@ const conditionalAgentscoreGatePlugin: FastifyPluginAsync (conditionalAgentscoreGatePlugin as unknown as Record)[Symbol.for('skip-override')] = true; export const conditionalAgentscoreGate = conditionalAgentscoreGatePlugin; -// --------------------------------------------------------------------------- -// AIP gate (Agentic Identity Protocol): verifies a key-bound Agent Identity Token (AIT) -// from a trusted IdP. Cryptographic identity only; enrich via /v1/assess from the handler. -// --------------------------------------------------------------------------- - -const AIT_STATE_KEY = '__agentscoreAit'; - -export interface AipGateFastifyOptions extends AipGateOptions { - /** Custom denial responder. Defaults to a 401/403 `application/problem+json` reply. */ - onDenied?: (req: FastifyRequest, reply: FastifyReply, body: ReturnType) => void | Promise; -} - -function defaultAipOnDenied(_req: FastifyRequest, reply: FastifyReply, body: ReturnType): void { - reply.code(body.status).header('content-type', 'application/problem+json').send(body); -} - -/** - * Fastify plugin that requires a valid AIT on every request in its scope. - * - * ```ts - * import { JwksCache } from '@agent-score/commerce'; - * import { aipGate, getVerifiedAit } from '@agent-score/commerce/identity/fastify'; - * - * const jwks = new JwksCache({ trustedIssuers: ['https://issuer.example'] }); - * await app.register(aipGate, { jwks }); - * app.post('/checkout', (req, reply) => reply.send({ buyer: getVerifiedAit(req)?.payload.identity?.email })); - * ``` - */ -const aipGatePlugin: FastifyPluginAsync = async (fastify, options) => { - const { onDenied = defaultAipOnDenied, ...gateOpts } = options; - fastify.addHook('preHandler', async (request, reply) => { - const result = await evaluateAipParts( - { method: request.method, url: request.url, headers: request.headers as Record }, - gateOpts, - ); - if (!result.ok) { - await onDenied(request, reply, result.body); - return; - } - (request as unknown as Record)[AIT_STATE_KEY] = result.ait; - }); -}; -(aipGatePlugin as unknown as Record)[Symbol.for('skip-override')] = true; -export const aipGate = aipGatePlugin; - -/** Conditional AIP plugin: only verifies when an `Agent-Identity` header is present. */ -const conditionalAipGatePlugin: FastifyPluginAsync = async (fastify, options) => { - const { onDenied = defaultAipOnDenied, ...gateOpts } = options; - fastify.addHook('preHandler', async (request, reply) => { - if (!hasAgentIdentityHeaderNode(request.headers as Record)) return; - const result = await evaluateAipParts( - { method: request.method, url: request.url, headers: request.headers as Record }, - gateOpts, - ); - if (!result.ok) { - await onDenied(request, reply, result.body); - return; - } - (request as unknown as Record)[AIT_STATE_KEY] = result.ait; - }); -}; -(conditionalAipGatePlugin as unknown as Record)[Symbol.for('skip-override')] = true; -export const conditionalAipGate = conditionalAipGatePlugin; - -/** Read the verified AIT attached to a Fastify request by {@link aipGate}. */ -export function getVerifiedAit(request: FastifyRequest): VerifiedAit | undefined { - return (request as unknown as Record)[AIT_STATE_KEY]; -} - /** * Read the stable pairwise {@link OperatorHandle} for the ACCOUNT behind this request's * operator token. This is what durable merchant state (prepaid balances first) should key @@ -339,8 +267,8 @@ export function getVerifiedAit(request: FastifyRequest): VerifiedAit | undefined * Synchronous and free: the handle rides the gate's existing `/v1/assess` call, so reading * it costs no extra round trip and nothing extra against the merchant's quota. * - * Returns `undefined` when the gate did not run, no operator token was presented (wallet or - * AIT paths), or the API has no handle salt configured. Available on denied requests too, + * Returns `undefined` when the gate did not run, no operator token was presented (wallet + * paths), or the API has no handle salt configured. Available on denied requests too, * so a merchant recording a denial against a buyer can still key it. */ export function getOperatorHandle(request: FastifyRequest): OperatorHandle | undefined { diff --git a/src/identity/hono.ts b/src/identity/hono.ts index 2d0cba4..f485c33 100644 --- a/src/identity/hono.ts +++ b/src/identity/hono.ts @@ -1,11 +1,8 @@ import { denialReasonStatus } from '../_denial'; import { denialReasonToBody } from '../_response'; -import { buildAipErrorBody, evaluateAipRequest, type AipGateOptions } from '../aip/gate'; -import { hasAgentIdentityHeader } from '../aip/request'; import { createAgentScoreCore } from '../core'; import { shouldRunConditionalGate } from '../payment/payment_header'; import { extractPaymentSigner, readX402PaymentHeader } from '../signer'; -import type { VerifiedAit } from '../aip/verify'; import type { AgentIdentity, AgentScoreCore, @@ -238,71 +235,6 @@ export function conditionalAgentscoreGate(options: AgentScoreGateOptions): Middl }; } -// --------------------------------------------------------------------------- -// AIP gate (Agentic Identity Protocol): verifies a key-bound Agent Identity Token (AIT) -// from a trusted IdP instead of an opaque operator token. Cryptographic identity only; -// merchants who want compliance enrichment feed the verified claims to /v1/assess. Hono is -// Fetch-native, so this reuses `verifyAitRequest(c.req.raw)` directly while keeping the same -// middleware + `getVerifiedAit` accessor shape as the express/fastify adapters. -// --------------------------------------------------------------------------- - -const AIT_CONTEXT_KEY = '__agentscoreAit'; - -export interface AipGateHonoOptions extends AipGateOptions { - /** Custom denial responder. Defaults to a 401/403 `application/problem+json` Response. */ - onDenied?: (c: Context, body: ReturnType) => Response | Promise; -} - -function defaultAipOnDenied(_c: Context, body: ReturnType): Response { - return new Response(JSON.stringify(body), { - status: body.status, - headers: { 'content-type': 'application/problem+json' }, - }); -} - -/** - * Hono middleware that requires a valid AIT on every request it guards. - * - * ```ts - * import { JwksCache } from '@agent-score/commerce'; - * import { aipGate, getVerifiedAit } from '@agent-score/commerce/identity/hono'; - * - * const jwks = new JwksCache({ trustedIssuers: ['https://issuer.example'] }); // AgentScore always trusted - * app.post('/checkout', aipGate({ jwks }), (c) => { - * const ait = getVerifiedAit(c)!; - * return c.json({ buyer: ait.payload.identity?.email }); - * }); - * ``` - */ -export function aipGate(options: AipGateHonoOptions): MiddlewareHandler { - const { onDenied = defaultAipOnDenied, ...gateOpts } = options; - return async (c, next) => { - const result = await evaluateAipRequest(c.req.raw, gateOpts); - if (!result.ok) { - return onDenied(c, result.body); - } - c.set(AIT_CONTEXT_KEY, result.ait); - await next(); - }; -} - -/** Wrap {@link aipGate} so it only runs when an `Agent-Identity` header is present. */ -export function conditionalAipGate(options: AipGateHonoOptions): MiddlewareHandler { - const gate = aipGate(options); - return async (c, next) => { - if (!hasAgentIdentityHeader(c.req.raw)) { - await next(); - return; - } - return gate(c, next); - }; -} - -/** Read the verified AIT attached to a Hono `Context` by {@link aipGate}. */ -export function getVerifiedAit(c: Context): VerifiedAit | undefined { - return c.get(AIT_CONTEXT_KEY) as VerifiedAit | undefined; -} - /** * Read the stable pairwise {@link OperatorHandle} for the ACCOUNT behind this request's * operator token. This is what durable merchant state (prepaid balances first) should key @@ -312,8 +244,8 @@ export function getVerifiedAit(c: Context): VerifiedAit | undefined { * Synchronous and free: the handle rides the gate's existing `/v1/assess` call, so reading * it costs no extra round trip and nothing extra against the merchant's quota. * - * Returns `undefined` when the gate did not run, no operator token was presented (wallet or - * AIT paths), or the API has no handle salt configured. Available on denied requests too, + * Returns `undefined` when the gate did not run, no operator token was presented (wallet + * paths), or the API has no handle salt configured. Available on denied requests too, * so a merchant recording a denial against a buyer can still key it. */ export function getOperatorHandle(c: Context): OperatorHandle | undefined { diff --git a/src/identity/nextjs.ts b/src/identity/nextjs.ts index 99e030b..8c017cf 100644 --- a/src/identity/nextjs.ts +++ b/src/identity/nextjs.ts @@ -44,7 +44,7 @@ export function withAgentScoreGate signerVerdict @@ -174,7 +171,7 @@ export function withAgentScoreGate( * the token rotating, expiring or being revoked, whereas token-keyed state is * stranded on every rotation. Rides the gate's existing `/v1/assess` call, so it * costs no extra round trip and nothing extra against quota. `undefined` on wallet - * or AIT paths, or when the API has no handle salt configured. */ + * paths, or when the API has no handle salt configured. */ operatorHandle?: OperatorHandle; /** Set to `true` only when the gate fail-open'd due to AgentScore-side infra failure * (429/5xx/network timeout). Compliance was NOT enforced this request: log/alert. */ @@ -231,62 +228,3 @@ export function withConditionalAgentScoreGate( return wrapped(req, ctx); }; } - -// --------------------------------------------------------------------------- -// AIP gate (Agentic Identity Protocol): Web Fetch -// -// `createAipGate` verifies a key-bound Agent Identity Token (AIT) from a trusted IdP and -// returns a guard result; `withAipGate` wraps a handler. Fetch-native, so it reuses -// `verifyAitRequest` directly. Identity verification only: merchants enrich via /v1/assess. -// --------------------------------------------------------------------------- - -export type AipGuardResult = - | { allowed: true; ait: VerifiedAit } - | { allowed: false; response: Response }; - -export interface AipGateWebOptions extends AipGateOptions { - /** Custom denial responder. Defaults to a 401/403 `application/problem+json` Response. */ - onDenied?: (req: Request, body: ReturnType) => Response | Promise; -} - -const defaultAipResponse = (body: ReturnType): Response => - new Response(JSON.stringify(body), { status: body.status, headers: { 'content-type': 'application/problem+json' } }); - -/** Create a Web Fetch AIP guard: returns `{ allowed, ait }` or `{ allowed: false, response }`. */ -export function createAipGate(options: AipGateWebOptions): (req: Request) => Promise { - const { onDenied, ...gateOpts } = options; - return async (req: Request): Promise => { - const result = await evaluateAipRequest(req, gateOpts); - if (result.ok) { return { allowed: true, ait: result.ait }; } - const body = result.body; - const response = onDenied ? await onDenied(req, body) : defaultAipResponse(body); - return { allowed: false, response }; - }; -} - -/** Wrap a Web Fetch handler with the AIP gate. Denied requests return the problem+json Response. */ -export function withAipGate( - options: AipGateWebOptions, - handler: (req: Request, gate: { ait: VerifiedAit }, ctx?: TCtx) => Response | Promise, -): (req: Request, ctx?: TCtx) => Promise { - const guard = createAipGate(options); - return async (req, ctx) => { - const result = await guard(req); - if (!result.allowed) { return result.response; } - return handler(req, { ait: result.ait }, ctx); - }; -} - -/** Conditional variant: only runs the AIP gate when an `Agent-Identity` header is present. */ -export function withConditionalAipGate( - options: AipGateWebOptions, - handler: (req: Request, gate: { ait?: VerifiedAit }, ctx?: TCtx) => Response | Promise, -): (req: Request, ctx?: TCtx) => Promise { - const guard = createAipGate(options); - return async (req, ctx) => { - if (!hasAgentIdentityHeader(req)) { return handler(req, {}, ctx); } - const result = await guard(req); - if (!result.allowed) { return result.response; } - return handler(req, { ait: result.ait }, ctx); - }; -} diff --git a/src/index.ts b/src/index.ts index 596ae1a..232bedf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,11 +33,9 @@ export { export { buildVerificationRequiredBody, denialReasonToBody } from './_response'; export { buildA2AAgentCard, - aipA2AExtension, ucpA2AExtension, A2A_DEFAULT_TRANSPORT, A2A_PROTOCOL_VERSION, - AIP_A2A_EXTENSION_URI, UCP_A2A_EXTENSION_URI, type A2AAgentCard, type A2AAgentCardCapabilities, @@ -114,7 +112,6 @@ export { type ReferenceIdFn, type RunGateFn, type SettleOutcome, - buildAipTrustedIssuers, getIdentityStatus, makeMppxComposeHook, pricingResult, @@ -176,59 +173,3 @@ export { type MalformedPaymentCredential, } from './payment/payment_header'; export { buildIdentityBootstrap, type IdentityBootstrapBlock } from './challenge/identity'; -// AIP (Agentic Identity Protocol): AIT verification (verifier role) + RFC 9421 signing. -export { - AGENT_IDENTITY_HEADER, - verifyAit, - type VerifiedAit, - type VerifyAitFailure, - type VerifyAitOptions, - type VerifyAitResult, - type VerifyRequestContext, -} from './aip/verify'; -export { - AIP_COVERED_COMPONENTS, - AIP_SIGNATURE_TAG, - MAX_POP_WINDOW_SECONDS, - signMessage, - verifyMessageSignature, - type SignMessageInput, - type VerifyFailureReason, - type VerifyMessageSignatureInput, - type VerifyMessageSignatureResult, -} from './aip/http-signature'; -export { - AGENTSCORE_CANONICAL_ISSUER, - HARD_MAX_CACHE_SECONDS, - JWKS_WELL_KNOWN_PATH, - JwksCache, - canonicalizeIssuer, - type JwksCacheOptions, - type JwksLookupResult, -} from './aip/jwks'; -export { - isAitShape, - validateAitPayload, - type AitHeader, - type AitPayload, - type AitValidationResult, - type AmrValue, - type IdentityClaim, - type IntentClaim, - type TrustLevel, -} from './aip/types'; -export { buildVerifyContextFromRequest, hasAgentIdentityHeader } from './aip/request'; -export { - aipErrorCode, - aipErrorStatus, - buildAipErrorBody, - buildAipWeakAuthBody, - checkTrustRequirements, - evaluateAipParts, - evaluateAipRequest, - verifyAitRequest, - type AipErrorRequirements, - type AipGateEvaluation, - type AipGateOptions, - type AipGateResult, -} from './aip/gate'; diff --git a/src/payment/payment_header.ts b/src/payment/payment_header.ts index 09ced1a..cc5b9df 100644 --- a/src/payment/payment_header.ts +++ b/src/payment/payment_header.ts @@ -60,14 +60,12 @@ export function hasPaymentHeader(input: Request | HeadersLike): boolean { export const VERIFICATION_SESSION_HEADER = 'X-Verification-Session'; export const VERIFICATION_SESSION_VALUE = 'create'; -/** True when the request carries an identity: an operator token, a wallet address, or an AIP - * `Agent-Identity` token. */ +/** True when the request carries an identity: an operator token or a wallet address. */ export function hasIdentityHeader(input: Request | HeadersLike): boolean { const headers = asHeaders(input); return Boolean( readHeader(headers, 'x-operator-token') || - readHeader(headers, 'x-wallet-address') || - readHeader(headers, 'agent-identity')?.split(',').some((s) => s.trim().length > 0), + readHeader(headers, 'x-wallet-address'), ); } diff --git a/tests/_response.test.ts b/tests/_response.test.ts index 2106e2a..1eae695 100644 --- a/tests/_response.test.ts +++ b/tests/_response.test.ts @@ -55,7 +55,7 @@ describe('denialReasonToBody', () => { agent_memory: { save_for_future_agentscore_gates: true, pattern_summary: 'test', - quickstart: 'https://docs/agent-identity', + quickstart: 'https://docs/passport', identity_check_endpoint: 'https://api.agentscore.com/v1/credentials', identity_paths: { wallet: 'X-Wallet-Address', operator_token: 'X-Operator-Token' }, bootstrap: 'Follow verify_url', diff --git a/tests/agent_memory_aip.test.ts b/tests/agent_memory_aip.test.ts deleted file mode 100644 index 20bab21..0000000 --- a/tests/agent_memory_aip.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * `buildAgentMemoryHint` × AIP. - * - * The cross-merchant memory hint advertises the AIT identity path ONLY when the merchant opted - * into AIP (passed a non-empty trusted-issuer list). Merchants that don't accept AITs must not - * tell agents to present one: that would be wrong guidance and leak a capability the route - * doesn't honor. Verifies both branches. - */ -import { describe, expect, it } from 'vitest'; -import { buildAgentMemoryHint } from '../src/core'; - -describe('buildAgentMemoryHint × AIP', () => { - it('omits the agent_identity path + aip_trusted_issuers when no issuers are configured', () => { - const hint = buildAgentMemoryHint(); - expect(hint.identity_paths.wallet).toBeTruthy(); - expect(hint.identity_paths.operator_token).toBeTruthy(); - expect(hint.identity_paths.agent_identity).toBeUndefined(); - expect(hint.aip_trusted_issuers).toBeUndefined(); - }); - - it('omits AIP guidance for an empty issuer list (opted out)', () => { - const hint = buildAgentMemoryHint([]); - expect(hint.identity_paths.agent_identity).toBeUndefined(); - expect(hint.aip_trusted_issuers).toBeUndefined(); - }); - - it('advertises the agent_identity path + issuers when AIP is configured', () => { - const issuers = ['https://issuer.example', 'https://www.agentscore.com']; - const hint = buildAgentMemoryHint(issuers); - expect(hint.aip_trusted_issuers).toEqual(issuers); - expect(hint.identity_paths.agent_identity).toMatch(/Agent-Identity/); - expect(hint.identity_paths.agent_identity).toMatch(/RFC 9421/); - // The opaque-token + wallet paths remain present (AIP is additive, not a replacement). - expect(hint.identity_paths.wallet).toBeTruthy(); - expect(hint.identity_paths.operator_token).toBeTruthy(); - }); -}); diff --git a/tests/aip_adapters.test.ts b/tests/aip_adapters.test.ts deleted file mode 100644 index 4c41826..0000000 --- a/tests/aip_adapters.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** - * AIP gate wiring across the non-Hono adapters: express, fastify, web, nextjs, plus the - * `verifyAitParts` (Node header-map) entry point and `buildVerifyContextFromParts`. - * - * Hono has its own dedicated suite (aip_hono_gate.test.ts); this covers the rest so every - * framework adapter is exercised end-to-end with a real signed AIT. - */ -import Fastify from 'fastify'; -import { SignJWT, exportJWK, generateKeyPair, type JWK } from 'jose'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { verifyAitParts } from '../src/aip/gate'; -import { signMessage } from '../src/aip/http-signature'; -import { JwksCache } from '../src/aip/jwks'; -import { buildVerifyContextFromParts } from '../src/aip/request'; -import type { NextFunction, Request as ExpressRequest, Response as ExpressResponse } from 'express'; - -const ISS = 'https://issuer.example'; -const KID = 'partner-key-2026-05'; - -let idpPrivate: CryptoKey; -let idpPublicJwk: JWK; -let agentPrivateJwk: JWK; -let agentPublicJwk: JWK; - -beforeAll(async () => { - const idp = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - idpPrivate = idp.privateKey; - idpPublicJwk = { ...(await exportJWK(idp.publicKey)), kid: KID, use: 'sig', alg: 'EdDSA' }; - const agent = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - agentPrivateJwk = await exportJWK(agent.privateKey); - agentPublicJwk = await exportJWK(agent.publicKey); -}); - -const jwks = () => - new JwksCache({ - trustedIssuers: [ISS], - fetchImpl: vi.fn(async () => ({ - ok: true, - status: 200, - headers: { get: () => 'max-age=300' }, - json: async () => ({ keys: [idpPublicJwk] }), - })), - }); - -const mintAit = async (): Promise => - new SignJWT({ - aip_version: '0.1', - sub: 'user_abc', - cnf: { jwk: agentPublicJwk }, - agent: { provider: 'anthropic' }, - trust_level: 'human_present', - identity: { email: 'b@example.com', email_verified: true }, - }) - .setProtectedHeader({ alg: 'EdDSA', typ: 'jwt', kid: KID }) - .setIssuer(ISS) - .setIssuedAt(1715400000) - .setExpirationTime(1715400300) - .sign(idpPrivate); - -const AUTHORITY = 'wine-merchant.com'; -const PATH = '/checkout'; -const NOW = 1715400020; - -const sigHeaders = async (token: string, opts: { signedAuthority?: string; hostHeader?: string } = {}): Promise> => { - const { signatureInput, signature } = await signMessage({ - method: 'POST', - authority: opts.signedAuthority ?? AUTHORITY, - path: PATH, - agentIdentity: token, - privateJwk: agentPrivateJwk, - publicJwk: agentPublicJwk, - created: 1715400010, - // PoP verifier requires `expires` (replay-window hardening); 60s window like pay. - expires: 1715400070, - }); - return { host: opts.hostHeader ?? AUTHORITY, 'agent-identity': token, 'signature-input': signatureInput, signature }; -}; - -describe('buildVerifyContextFromParts', () => { - it('derives path + authority from a bare url and host header', async () => { - const headers = await sigHeaders(await mintAit()); - const ctx = buildVerifyContextFromParts({ method: 'POST', url: '/checkout?x=1', headers }); - expect(ctx.method).toBe('POST'); - expect(ctx.path).toBe('/checkout'); - expect(ctx.authority).toBe(AUTHORITY); - expect(ctx.agentIdentityHeaders).toHaveLength(1); - }); - - it('handles a header value array (Node can fold repeats)', () => { - const ctx = buildVerifyContextFromParts({ - method: 'POST', - url: '/x', - headers: { 'agent-identity': ['aaa.bbb.ccc', 'ddd.eee.fff'], host: AUTHORITY }, - }); - expect(ctx.agentIdentityHeaders).toEqual(['aaa.bbb.ccc', 'ddd.eee.fff']); - }); -}); - -describe('verifyAitParts', () => { - it('verifies a valid signed AIT from a Node header map', async () => { - const headers = await sigHeaders(await mintAit()); - const r = await verifyAitParts({ method: 'POST', url: PATH, headers }, { jwks: jwks(), now: NOW }); - expect(r.ok).toBe(true); - if (r.ok) { expect(r.ait.payload.identity?.email).toBe('b@example.com'); } - }); - - it('fails with no_token when the header is absent', async () => { - const r = await verifyAitParts({ method: 'POST', url: PATH, headers: { host: AUTHORITY } }, { jwks: jwks(), now: NOW }); - expect(r).toEqual({ ok: false, failure: 'no_token' }); - }); -}); - -describe('authority pinning (AipGateOptions.authority)', () => { - // Behind a proxy / multi-vhost listener the inbound Host can't be trusted: the merchant pins - // the expected public host and the verifier binds the signature to the PIN, not Host. - const PINNED = 'public.example'; - const INBOUND = 'internal.local'; - - it('parts path: a signature over the pinned host verifies even when Host differs', async () => { - const headers = await sigHeaders(await mintAit(), { signedAuthority: PINNED, hostHeader: INBOUND }); - const denied = await verifyAitParts({ method: 'POST', url: PATH, headers }, { jwks: jwks(), now: NOW }); - expect(denied).toEqual({ ok: false, failure: 'pop_signature_invalid' }); // unpinned trusts Host → mismatch - const r = await verifyAitParts({ method: 'POST', url: PATH, headers }, { jwks: jwks(), now: NOW, authority: PINNED }); - expect(r.ok).toBe(true); - }); - - it('parts path: rejects a signature over the (different) inbound Host when pinned', async () => { - const headers = await sigHeaders(await mintAit(), { signedAuthority: INBOUND, hostHeader: INBOUND }); - const unpinned = await verifyAitParts({ method: 'POST', url: PATH, headers }, { jwks: jwks(), now: NOW }); - expect(unpinned.ok).toBe(true); // sanity: the signature itself is valid for the inbound host - const pinned = await verifyAitParts({ method: 'POST', url: PATH, headers }, { jwks: jwks(), now: NOW, authority: PINNED }); - expect(pinned).toEqual({ ok: false, failure: 'pop_signature_invalid' }); - }); - - it('request path: the pin overrides the Fetch Request Host/URL authority', async () => { - const { createAipGate } = await import('../src/identity/web'); - const headers = await sigHeaders(await mintAit(), { signedAuthority: PINNED, hostHeader: INBOUND }); - const req = () => new Request(`https://${INBOUND}${PATH}`, { method: 'POST', headers }); - - const unpinned = createAipGate({ jwks: jwks(), now: NOW }); - const denied = await unpinned(req()); - expect(denied.allowed).toBe(false); - - const pinnedGate = createAipGate({ jwks: jwks(), now: NOW, authority: PINNED }); - const allowed = await pinnedGate(req()); - expect(allowed.allowed).toBe(true); - }); - - it('express aipGate honors the pin', async () => { - const { aipGate, getVerifiedAit } = await import('../src/identity/express'); - const headers = await sigHeaders(await mintAit(), { signedAuthority: PINNED, hostHeader: INBOUND }); - const mw = aipGate({ jwks: jwks(), now: NOW, authority: PINNED }); - const req = { method: 'POST', url: PATH, headers } as unknown as ExpressRequest; - const res = mockRes(); - let nextCalled = false; - await mw(req, res as unknown as ExpressResponse, (() => { nextCalled = true; }) as NextFunction); - expect(nextCalled).toBe(true); - expect(getVerifiedAit(req)?.payload.identity?.email).toBe('b@example.com'); - }); -}); - -// Express middleware tested by direct invocation with mock req/res/next: deterministic, -// no HTTP stack (supertest isn't a dep). Express is referenced only for its types here. -interface MockRes { - statusCode: number; - contentType: string; - body: unknown; - status(s: number): MockRes; - type(t: string): MockRes; - json(b: unknown): MockRes; -} - -const mockRes = (): MockRes => ({ - statusCode: 0, - contentType: '', - body: undefined, - status(s) { this.statusCode = s; return this; }, - type(t) { this.contentType = t; return this; }, - json(b) { this.body = b; return this; }, -}); - -describe('express aipGate', () => { - const runMiddleware = async (headers: Record) => { - const { aipGate, getVerifiedAit } = await import('../src/identity/express'); - const mw = aipGate({ jwks: jwks(), now: NOW }); - const req = { method: 'POST', url: PATH, headers } as unknown as ExpressRequest; - const res = mockRes(); - let nextCalled = false; - await mw(req, res as unknown as ExpressResponse, (() => { nextCalled = true; }) as NextFunction); - return { req, res, nextCalled, getVerifiedAit }; - }; - - it('allows a valid AIT, calls next, and exposes claims', async () => { - const { req, nextCalled, getVerifiedAit } = await runMiddleware(await sigHeaders(await mintAit())); - expect(nextCalled).toBe(true); - expect(getVerifiedAit(req)?.payload.identity?.email).toBe('b@example.com'); - }); - - it('denies with 401 problem+json when no AIT (no next)', async () => { - const { res, nextCalled } = await runMiddleware({ host: AUTHORITY }); - expect(nextCalled).toBe(false); - expect(res.statusCode).toBe(401); - expect(res.contentType).toBe('application/problem+json'); - expect((res.body as { type: string }).type).toBe('urn:aip:error:agent_identity_required'); - }); - - it('denies with 403 for an untrusted issuer', async () => { - // mint from an issuer not on the trust list - const evil = await new SignJWT({ aip_version: '0.1', sub: 'u', cnf: { jwk: agentPublicJwk }, agent: { provider: 'x' } }) - .setProtectedHeader({ alg: 'EdDSA', typ: 'jwt', kid: KID }) - .setIssuer('https://evil.com') - .setIssuedAt(1715400000) - .setExpirationTime(1715400300) - .sign(idpPrivate); - const { res, nextCalled } = await runMiddleware(await sigHeaders(evil)); - expect(nextCalled).toBe(false); - expect(res.statusCode).toBe(403); - expect((res.body as { type: string }).type).toBe('urn:aip:error:untrusted_issuer'); - }); -}); - -describe('fastify aipGate', () => { - it('allows a valid AIT and denies a missing one', async () => { - const { aipGate, getVerifiedAit } = await import('../src/identity/fastify'); - const app = Fastify(); - await app.register(aipGate, { jwks: jwks(), now: NOW }); - app.post('/checkout', async (req) => ({ email: getVerifiedAit(req)?.payload.identity?.email ?? null })); - - const ok = await app.inject({ method: 'POST', url: '/checkout', headers: await sigHeaders(await mintAit()) }); - expect(ok.statusCode).toBe(200); - expect(ok.json()).toEqual({ email: 'b@example.com' }); - - const denied = await app.inject({ method: 'POST', url: '/checkout', headers: { host: AUTHORITY } }); - expect(denied.statusCode).toBe(401); - expect(denied.json().type).toBe('urn:aip:error:agent_identity_required'); - await app.close(); - }); -}); - -describe('web createAipGate / withAipGate', () => { - it('createAipGate allows + denies', async () => { - const { createAipGate } = await import('../src/identity/web'); - const guard = createAipGate({ jwks: jwks(), now: NOW }); - - const okReq = new Request(`https://${AUTHORITY}${PATH}`, { method: 'POST', headers: await sigHeaders(await mintAit()) }); - const ok = await guard(okReq); - expect(ok.allowed).toBe(true); - - const badReq = new Request(`https://${AUTHORITY}${PATH}`, { method: 'POST' }); - const bad = await guard(badReq); - expect(bad.allowed).toBe(false); - if (!bad.allowed) { expect(bad.response.status).toBe(401); } - }); - - it('withAipGate passes the verified ait to the handler', async () => { - const { withAipGate } = await import('../src/identity/web'); - const handler = withAipGate({ jwks: jwks(), now: NOW }, (_req, { ait }) => - Response.json({ email: ait.payload.identity?.email ?? null }), - ); - const res = await handler(new Request(`https://${AUTHORITY}${PATH}`, { method: 'POST', headers: await sigHeaders(await mintAit()) })); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ email: 'b@example.com' }); - }); - - it('withConditionalAipGate flows through when no Agent-Identity header', async () => { - const { withConditionalAipGate } = await import('../src/identity/web'); - const handler = withConditionalAipGate({ jwks: jwks(), now: NOW }, (_req, { ait }) => - Response.json({ hasAit: ait !== undefined }), - ); - const res = await handler(new Request(`https://${AUTHORITY}${PATH}`, { method: 'POST' })); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ hasAit: false }); - }); -}); - -describe('nextjs AIP re-exports', () => { - it('re-exports the web AIP gate', async () => { - const next = await import('../src/identity/nextjs'); - const web = await import('../src/identity/web'); - expect(next.createAipGate).toBe(web.createAipGate); - expect(next.withAipGate).toBe(web.withAipGate); - expect(next.withConditionalAipGate).toBe(web.withConditionalAipGate); - }); -}); diff --git a/tests/aip_gate.test.ts b/tests/aip_gate.test.ts deleted file mode 100644 index eff8f45..0000000 --- a/tests/aip_gate.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { SignJWT, exportJWK, generateKeyPair, type JWK } from 'jose'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { aipErrorCode, aipErrorStatus, buildAipErrorBody, buildAipPolicyDenyBody, verifyAitRequest } from '../src/aip/gate'; -import { signMessage } from '../src/aip/http-signature'; -import { JwksCache } from '../src/aip/jwks'; -import { hasAgentIdentityHeader } from '../src/aip/request'; -import type { VerifyAitFailure } from '../src/aip/verify'; - -const ISS = 'https://issuer.example'; -const KID = 'partner-key-2026-05'; - -let idpPrivate: CryptoKey; -let idpPublicJwk: JWK; -let agentPrivateJwk: JWK; -let agentPublicJwk: JWK; - -beforeAll(async () => { - const idp = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - idpPrivate = idp.privateKey; - idpPublicJwk = { ...(await exportJWK(idp.publicKey)), kid: KID, use: 'sig', alg: 'EdDSA' }; - const agent = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - agentPrivateJwk = await exportJWK(agent.privateKey); - agentPublicJwk = await exportJWK(agent.publicKey); -}); - -const jwks = () => - new JwksCache({ - trustedIssuers: [ISS], - fetchImpl: vi.fn(async () => ({ - ok: true, - status: 200, - headers: { get: () => 'max-age=300' }, - json: async () => ({ keys: [idpPublicJwk] }), - })), - }); - -const mintAit = async (iss = ISS): Promise => - new SignJWT({ - aip_version: '0.1', - sub: 'user_abc', - cnf: { jwk: agentPublicJwk }, - agent: { provider: 'anthropic' }, - trust_level: 'human_present', - identity: { email: 'b@example.com', email_verified: true, age_over_21: true }, - }) - .setProtectedHeader({ alg: 'EdDSA', typ: 'jwt', kid: KID }) - .setIssuer(iss) - .setIssuedAt(1715400000) - .setExpirationTime(1715400300) - .sign(idpPrivate); - -const signedRequest = async (token: string, url = 'https://wine-merchant.com/checkout'): Promise => { - const u = new URL(url); - const { signatureInput, signature } = await signMessage({ - method: 'POST', - authority: u.host, - path: u.pathname, - agentIdentity: token, - privateJwk: agentPrivateJwk, - publicJwk: agentPublicJwk, - created: 1715400010, - // PoP verifier requires `expires` (replay-window hardening); 60s window like pay. - expires: 1715400070, - }); - return new Request(url, { - method: 'POST', - headers: { host: u.host, 'agent-identity': token, 'signature-input': signatureInput, signature }, - }); -}; - -const NOW = 1715400020; - -describe('verifyAitRequest', () => { - it('verifies a valid signed request and returns the claims', async () => { - const req = await signedRequest(await mintAit()); - const r = await verifyAitRequest(req, { jwks: jwks(), now: NOW }); - expect(r.ok).toBe(true); - if (r.ok) { - expect(r.ait.iss).toBe(ISS); - expect(r.ait.payload.identity?.age_over_21).toBe(true); - } - }); - - it('fails with no_token when there is no Agent-Identity header', async () => { - const req = new Request('https://wine-merchant.com/checkout', { method: 'POST' }); - const r = await verifyAitRequest(req, { jwks: jwks(), now: NOW }); - expect(r).toEqual({ ok: false, failure: 'no_token' }); - }); - - it('fails with untrusted_issuer for an unknown IdP', async () => { - const req = await signedRequest(await mintAit('https://evil.com')); - const r = await verifyAitRequest(req, { jwks: jwks(), now: NOW }); - expect(r).toEqual({ ok: false, failure: 'untrusted_issuer' }); - }); - - it('fails with pop_signature_invalid when the path was tampered', async () => { - // sign for /checkout but send to /admin - const token = await mintAit(); - const signed = await signedRequest(token, 'https://wine-merchant.com/checkout'); - const tampered = new Request('https://wine-merchant.com/admin', { method: 'POST', headers: signed.headers }); - const r = await verifyAitRequest(tampered, { jwks: jwks(), now: NOW }); - expect(r).toEqual({ ok: false, failure: 'pop_signature_invalid' }); - }); -}); - -describe('hasAgentIdentityHeader', () => { - it('detects the header', () => { - const req = new Request('https://m.com/x', { method: 'POST', headers: { 'agent-identity': 'a.b.c' } }); - expect(hasAgentIdentityHeader(req)).toBe(true); - }); -}); - -describe('aipErrorStatus', () => { - it('returns 403 for trust/claims failures', () => { - expect(aipErrorStatus('untrusted_issuer')).toBe(403); - expect(aipErrorStatus('invalid_claims')).toBe(403); - }); - - it('returns 401 for presence/signature failures', () => { - for (const f of ['no_token', 'pop_signature_missing', 'expired_token', 'malformed_token', 'idp_signature_invalid', 'pop_signature_invalid'] as VerifyAitFailure[]) { - expect(aipErrorStatus(f)).toBe(401); - } - }); - - it('returns 503 for key_unavailable (IdP JWKS unreachable: retryable, not a client auth failure)', () => { - expect(aipErrorStatus('key_unavailable')).toBe(503); - }); -}); - -describe('aipErrorCode', () => { - it('maps presence failures to agent_identity_required', () => { - expect(aipErrorCode('no_token')).toBe('agent_identity_required'); - expect(aipErrorCode('pop_signature_missing')).toBe('agent_identity_required'); - }); - - it('maps signature/malformed failures to invalid_signature', () => { - expect(aipErrorCode('idp_signature_invalid')).toBe('invalid_signature'); - expect(aipErrorCode('pop_signature_invalid')).toBe('invalid_signature'); - expect(aipErrorCode('malformed_token')).toBe('invalid_signature'); - }); - - it('maps key_unavailable to idp_unavailable (infra failure, distinct from a bad signature)', () => { - expect(aipErrorCode('key_unavailable')).toBe('idp_unavailable'); - }); - - it('passes through untrusted_issuer and expired_token', () => { - expect(aipErrorCode('untrusted_issuer')).toBe('untrusted_issuer'); - expect(aipErrorCode('expired_token')).toBe('expired_token'); - }); - - it('maps invalid_claims to insufficient_claims', () => { - expect(aipErrorCode('invalid_claims')).toBe('insufficient_claims'); - }); -}); - -describe('buildAipErrorBody', () => { - it('produces an RFC 9457 problem-details body with a urn:aip:error type', () => { - const body = buildAipErrorBody('untrusted_issuer'); - expect(body).toEqual({ - type: 'urn:aip:error:untrusted_issuer', - title: 'untrusted issuer', - status: 403, - detail: expect.stringContaining('trusted-issuer'), - }); - }); - - it('status field matches aipErrorStatus', () => { - expect(buildAipErrorBody('no_token').status).toBe(401); - expect(buildAipErrorBody('invalid_claims').status).toBe(403); - }); -}); - -describe('buildAipPolicyDenyBody', () => { - it('keeps the canonical AgentScore body fields on top of the RFC 9457 envelope', () => { - const body = buildAipPolicyDenyBody('wallet_not_trusted', ['sanctions_flagged'], { - error: { code: 'wallet_not_trusted', message: 'denied' }, - reasons: ['sanctions_flagged'], - agent_instructions: '{"action":"contact_support"}', - }); - expect(body.type).toBe('urn:aip:error:insufficient_claims'); - expect(body.status).toBe(403); - expect(body.error).toEqual({ code: 'wallet_not_trusted', message: 'denied' }); - expect(body.agent_instructions).toBe('{"action":"contact_support"}'); - }); - - it('reserves the problem+json envelope: merchant extra cannot clobber type/title/status/detail', () => { - // A merchant onBeforeSession `extra` rides through denialReasonToBody unfiltered; a smuggled - // `status` would otherwise rewrite both the envelope AND the HTTP status Checkout derives - // from it. The canonical envelope must always win. - const body = buildAipPolicyDenyBody('wallet_not_trusted', ['kyc_required'], { - error: { code: 'wallet_not_trusted', message: 'denied' }, - status: 200, - type: 'https://evil.example/ok', - title: 'all good', - detail: 'nothing to see', - order_id: 'ord_1', - }); - expect(body.status).toBe(403); - expect(body.type).toBe('urn:aip:error:insufficient_claims'); - expect(body.title).toBe('insufficient claims'); - expect(body.detail).toContain('kyc_required'); - // Non-reserved merchant fields still ride through. - expect(body.order_id).toBe('ord_1'); - expect(body.error).toEqual({ code: 'wallet_not_trusted', message: 'denied' }); - }); -}); diff --git a/tests/aip_hono_gate.test.ts b/tests/aip_hono_gate.test.ts deleted file mode 100644 index 8f6d5cc..0000000 --- a/tests/aip_hono_gate.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * AIP gate wiring for the Hono adapter: the context-getter sibling of express/fastify. - * - * `aip_adapters.test.ts` covers express/fastify/web/nextjs; this is Hono's dedicated suite, - * exercising `aipGate` / `conditionalAipGate` / `getVerifiedAit` end-to-end with a real - * signed AIT (IdP JWT + RFC 9421 proof-of-possession). - */ -import { Hono } from 'hono'; -import { SignJWT, exportJWK, generateKeyPair, type JWK } from 'jose'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { signMessage } from '../src/aip/http-signature'; -import { JwksCache } from '../src/aip/jwks'; -import { aipGate, conditionalAipGate, getVerifiedAit } from '../src/identity/hono'; - -const ISS = 'https://issuer.example'; -const KID = 'partner-key-2026-05'; - -let idpPrivate: CryptoKey; -let idpPublicJwk: JWK; -let agentPrivateJwk: JWK; -let agentPublicJwk: JWK; - -beforeAll(async () => { - const idp = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - idpPrivate = idp.privateKey; - idpPublicJwk = { ...(await exportJWK(idp.publicKey)), kid: KID, use: 'sig', alg: 'EdDSA' }; - const agent = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - agentPrivateJwk = await exportJWK(agent.privateKey); - agentPublicJwk = await exportJWK(agent.publicKey); -}); - -const jwks = () => - new JwksCache({ - trustedIssuers: [ISS], - fetchImpl: vi.fn(async () => ({ - ok: true, - status: 200, - headers: { get: () => 'max-age=300' }, - json: async () => ({ keys: [idpPublicJwk] }), - })), - }); - -const AUTHORITY = 'wine-merchant.com'; -const PATH = '/checkout'; -const URL = `https://${AUTHORITY}${PATH}`; -const NOW = 1715400020; - -const mintAit = async (opts: { iss?: string; trustLevel?: string; auth?: Record } = {}): Promise => - new SignJWT({ - aip_version: '0.1', - sub: 'user_abc', - cnf: { jwk: agentPublicJwk }, - agent: { provider: 'anthropic' }, - trust_level: opts.trustLevel ?? 'human_present', - ...(opts.auth ? { auth: opts.auth } : {}), - identity: { email: 'b@example.com', email_verified: true }, - }) - .setProtectedHeader({ alg: 'EdDSA', typ: 'jwt', kid: KID }) - .setIssuer(opts.iss ?? ISS) - .setIssuedAt(1715400000) - .setExpirationTime(1715400300) - .sign(idpPrivate); - -const sigHeaders = async (token: string): Promise> => { - const { signatureInput, signature } = await signMessage({ - method: 'POST', - authority: AUTHORITY, - path: PATH, - agentIdentity: token, - privateJwk: agentPrivateJwk, - publicJwk: agentPublicJwk, - created: 1715400010, - // PoP verifier requires `expires` (replay-window hardening); 60s window like pay. - expires: 1715400070, - }); - return { host: AUTHORITY, 'agent-identity': token, 'signature-input': signatureInput, signature }; -}; - -describe('hono aipGate', () => { - const app = () => { - const a = new Hono(); - a.post('/checkout', aipGate({ jwks: jwks(), now: NOW }), (c) => - c.json({ email: getVerifiedAit(c)?.payload.identity?.email ?? null }), - ); - return a; - }; - - it('allows a valid AIT and exposes the claims via getVerifiedAit', async () => { - const res = await app().request(URL, { method: 'POST', headers: await sigHeaders(await mintAit()) }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ email: 'b@example.com' }); - }); - - it('denies with 401 problem+json when no AIT is presented', async () => { - const res = await app().request(URL, { method: 'POST', headers: { host: AUTHORITY } }); - expect(res.status).toBe(401); - expect(res.headers.get('content-type')).toContain('application/problem+json'); - expect((await res.json()).type).toBe('urn:aip:error:agent_identity_required'); - }); - - it('denies with 403 for an untrusted issuer', async () => { - const res = await app().request(URL, { method: 'POST', headers: await sigHeaders(await mintAit({ iss: 'https://evil.com' })) }); - expect(res.status).toBe(403); - expect((await res.json()).type).toBe('urn:aip:error:untrusted_issuer'); - }); -}); - -describe('hono conditionalAipGate', () => { - const app = () => { - const a = new Hono(); - a.post('/checkout', conditionalAipGate({ jwks: jwks(), now: NOW }), (c) => - c.json({ hasAit: getVerifiedAit(c) !== undefined }), - ); - return a; - }; - - it('flows through unauthenticated when no Agent-Identity header is present', async () => { - const res = await app().request(URL, { method: 'POST', headers: { host: AUTHORITY } }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ hasAit: false }); - }); - - it('enforces the gate when an Agent-Identity header is present', async () => { - const res = await app().request(URL, { method: 'POST', headers: await sigHeaders(await mintAit()) }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ hasAit: true }); - }); - - it('denies a present-but-invalid AIT (untrusted issuer) rather than flowing through', async () => { - const res = await app().request(URL, { method: 'POST', headers: await sigHeaders(await mintAit({ iss: 'https://evil.com' })) }); - expect(res.status).toBe(403); - }); -}); - -describe('hono aipGate: trust_level / auth.amr enforcement (standalone gate)', () => { - const trustApp = (aipOpts: Record) => { - const a = new Hono(); - a.post('/checkout', aipGate({ jwks: jwks(), now: NOW, ...aipOpts }), (c) => c.json({ ok: true })); - return a; - }; - - it('denies weak_auth (403 + required_trust_level) when trust_level is below the requirement', async () => { - const res = await trustApp({ requireTrustLevel: 'human_confirmed' }) - .request(URL, { method: 'POST', headers: await sigHeaders(await mintAit({ trustLevel: 'human_present' })) }); - expect(res.status).toBe(403); - expect(res.headers.get('content-type')).toContain('application/problem+json'); - const body = await res.json(); - expect(body.type).toBe('urn:aip:error:weak_auth'); - expect(body.required_trust_level).toBe('human_confirmed'); - }); - - it('denies weak_auth (403 + required_amr) when no auth.amr matches', async () => { - const res = await trustApp({ requireAmr: ['face', 'hwk'] }) - .request(URL, { method: 'POST', headers: await sigHeaders(await mintAit({ trustLevel: 'human_confirmed', auth: { amr: ['pwd'] } })) }); - expect(res.status).toBe(403); - const body = await res.json(); - expect(body.type).toBe('urn:aip:error:weak_auth'); - expect(body.required_amr).toEqual(['face', 'hwk']); - }); - - it('allows when trust_level + auth.amr satisfy the requirement', async () => { - const res = await trustApp({ requireTrustLevel: 'human_confirmed', requireAmr: ['face'] }) - .request(URL, { method: 'POST', headers: await sigHeaders(await mintAit({ trustLevel: 'human_confirmed', auth: { amr: ['face'] } })) }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ ok: true }); - }); - - it('attaches trusted_issuers on an untrusted_issuer denial when configured (self-correction)', async () => { - const res = await trustApp({ trustedIssuers: [ISS] }) - .request(URL, { method: 'POST', headers: await sigHeaders(await mintAit({ iss: 'https://evil.com' })) }); - expect(res.status).toBe(403); - const body = await res.json(); - expect(body.type).toBe('urn:aip:error:untrusted_issuer'); - expect(body.trusted_issuers).toEqual([ISS]); - }); -}); diff --git a/tests/aip_http_signature.test.ts b/tests/aip_http_signature.test.ts deleted file mode 100644 index 321e238..0000000 --- a/tests/aip_http_signature.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { generateKeyPair, exportJWK, calculateJwkThumbprint, importJWK, type JWK } from 'jose'; -import { beforeAll, describe, expect, it } from 'vitest'; -import { - AIP_COVERED_COMPONENTS, - AIP_SIGNATURE_TAG, - buildSignatureBase, - normalizeAuthority, - parseSignatureInput, - parseSignatureValue, - signMessage, - verifyMessageSignature, - type SignMessageInput, -} from '../src/aip/http-signature'; - -let privateJwk: JWK; -let publicJwk: JWK; -let thumbprint: string; - -beforeAll(async () => { - const { privateKey, publicKey } = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - privateJwk = await exportJWK(privateKey); - publicJwk = await exportJWK(publicKey); - thumbprint = await calculateJwkThumbprint(publicJwk, 'sha256'); -}); - -const baseReq = { - method: 'POST', - authority: 'wine-merchant.com', - path: '/checkout', - agentIdentity: 'eyJhbGciOiJFZERTQSJ9.payload.sig', -}; - -const roundTrip = async (overrides: Partial = {}) => { - // The verifier now REQUIRES `expires` (replay-window hardening), so default to a 60s window - // (matching pay's signer) unless a test overrides it. `signMessage` itself omits `expires` by - // default: that's only the serialization-format default, exercised explicitly below. - const created = overrides.created ?? Math.floor(Date.now() / 1000); - const { signatureInput, signature } = await signMessage({ - ...baseReq, - privateJwk, - publicJwk, - expires: created + 60, - ...overrides, - }); - return { signatureInput, signature }; -}; - -describe('normalizeAuthority', () => { - it('lowercases the host', () => { - expect(normalizeAuthority('Wine-Merchant.COM')).toBe('wine-merchant.com'); - }); - - it('drops default ports 80 and 443', () => { - expect(normalizeAuthority('host.com:443')).toBe('host.com'); - expect(normalizeAuthority('host.com:80')).toBe('host.com'); - }); - - it('keeps non-default ports', () => { - expect(normalizeAuthority('host.com:3003')).toBe('host.com:3003'); - }); - - it('leaves IPv6 literals without a port intact', () => { - expect(normalizeAuthority('[::1]')).toBe('[::1]'); - }); -}); - -describe('buildSignatureBase', () => { - it('emits one line per component plus @signature-params, no trailing newline', () => { - const base = buildSignatureBase( - { components: ['@method', '@authority', '@path', 'agent-identity'], created: 1715400000, keyid: 'kid', tag: 'agent-identity' }, - baseReq, - ); - const lines = base.split('\n'); - expect(lines[0]).toBe('"@method": POST'); - expect(lines[1]).toBe('"@authority": wine-merchant.com'); - expect(lines[2]).toBe('"@path": /checkout'); - expect(lines[3]).toBe(`"agent-identity": ${baseReq.agentIdentity}`); - expect(lines[4]).toBe('"@signature-params": ("@method" "@authority" "@path" "agent-identity");created=1715400000;keyid="kid";tag="agent-identity"'); - expect(base.endsWith('\n')).toBe(false); - }); - - it('throws when a covered component has no value', () => { - expect(() => - buildSignatureBase({ components: ['@method', 'x-missing'] }, baseReq), - ).toThrow(); - }); -}); - -describe('parseSignatureInput', () => { - it('parses components and params from a single member', () => { - const header = 'ait=("@method" "@authority" "@path" "agent-identity");created=1715400000;expires=1715400060;keyid="abc";tag="agent-identity"'; - const parsed = parseSignatureInput(header); - expect(parsed?.label).toBe('ait'); - expect(parsed?.params.components).toEqual(['@method', '@authority', '@path', 'agent-identity']); - expect(parsed?.params.created).toBe(1715400000); - expect(parsed?.params.expires).toBe(1715400060); - expect(parsed?.params.keyid).toBe('abc'); - expect(parsed?.params.tag).toBe('agent-identity'); - }); - - it('selects the agent-identity member when a web-bot-auth member coexists', () => { - const header = - 'web-bot=("@authority");created=1;tag="web-bot-auth", ait=("@method" "@authority" "@path" "agent-identity");created=2;keyid="k";tag="agent-identity"'; - const parsed = parseSignatureInput(header); - expect(parsed?.label).toBe('ait'); - expect(parsed?.params.created).toBe(2); - }); - - it('rejects a sole untagged member (tag="agent-identity" is required)', () => { - const header = 'sig1=("@method" "@path");created=5;keyid="k"'; - expect(parseSignatureInput(header)).toBeNull(); - }); - - it('preserves the raw member value (rawParams) byte-for-byte', () => { - // The verifier echoes this into the "@signature-params" base line, so it must be the member - // value exactly as received: including a non-canonical param order. - const raw = '("@method" "@authority" "@path" "agent-identity");keyid="abc";created=1715400000;expires=1715400060;tag="agent-identity"'; - const parsed = parseSignatureInput(`ait=${raw}`); - expect(parsed?.rawParams).toBe(raw); - }); - - it('returns null when only non-AIP tagged members are present', () => { - const header = 'web-bot=("@authority");created=1;tag="web-bot-auth"'; - expect(parseSignatureInput(header)).toBeNull(); - }); - - it('returns null on malformed input', () => { - expect(parseSignatureInput('garbage')).toBeNull(); - }); -}); - -describe('parseSignatureValue', () => { - it('extracts a base64 byte sequence for the label', () => { - const bytes = parseSignatureValue('ait=:AQID:', 'ait'); - expect(bytes).toEqual(new Uint8Array([1, 2, 3])); - }); - - it('returns null for a missing label', () => { - expect(parseSignatureValue('ait=:AQID:', 'other')).toBeNull(); - }); - - it('does not split a byte sequence that contains a comma-like char inside base64', () => { - // base64 never contains commas, but ensure colon-delimited parsing is intact across members - const header = 'web-bot=:AAAA:, ait=:AQID:'; - expect(parseSignatureValue(header, 'ait')).toEqual(new Uint8Array([1, 2, 3])); - }); -}); - -describe('signMessage + verifyMessageSignature round trip', () => { - it('verifies a freshly signed request', async () => { - const { signatureInput, signature } = await roundTrip({ created: 1715400000 }); - const result = await verifyMessageSignature({ - ...baseReq, - signatureInput, - signature, - cnfJwk: publicJwk, - now: 1715400010, - }); - expect(result.ok).toBe(true); - }); - - it('sets keyid to the public key thumbprint and tag to agent-identity', async () => { - const { signatureInput } = await roundTrip(); - const parsed = parseSignatureInput(signatureInput); - expect(parsed?.params.keyid).toBe(thumbprint); - expect(parsed?.params.tag).toBe(AIP_SIGNATURE_TAG); - }); - - it('covers exactly the AIP minimum components by default', async () => { - const { signatureInput } = await roundTrip(); - const parsed = parseSignatureInput(signatureInput); - expect(parsed?.params.components).toEqual([...AIP_COVERED_COMPONENTS]); - }); - - it('verifies a signer that declared params in a NON-canonical order (raw @signature-params)', async () => { - // RFC 9421 puts no order on signature params. A spec-legal signer that emits keyid BEFORE - // created/expires signs over THAT serialization; the verifier must rebuild the base from the - // raw received member value, not a re-serialization in our canonical order. - const created = 1715400000; - const rawParams = `("@method" "@authority" "@path" "agent-identity");keyid="${thumbprint}";created=${created};expires=${created + 60};tag="agent-identity"`; - const base = [ - '"@method": POST', - '"@authority": wine-merchant.com', - '"@path": /checkout', - `"agent-identity": ${baseReq.agentIdentity}`, - `"@signature-params": ${rawParams}`, - ].join('\n'); - const key = await importJWK(privateJwk, 'EdDSA'); - const sigBytes = await globalThis.crypto.subtle.sign( - 'Ed25519', - key as CryptoKey, - new TextEncoder().encode(base) as unknown as ArrayBuffer, - ); - const signature = `ait=:${Buffer.from(new Uint8Array(sigBytes)).toString('base64')}:`; - const r = await verifyMessageSignature({ - ...baseReq, - signatureInput: `ait=${rawParams}`, - signature, - cnfJwk: publicJwk, - now: created + 10, - }); - expect(r.ok).toBe(true); - }); -}); - -describe('verifyMessageSignature failure modes', () => { - const verify = async (over: Partial[0]>, sig?: { signatureInput: string; signature: string }) => { - const s = sig ?? (await roundTrip({ created: 1715400000 })); - return verifyMessageSignature({ - ...baseReq, - signatureInput: s.signatureInput, - signature: s.signature, - cnfJwk: publicJwk, - now: 1715400010, - ...over, - }); - }; - - it('rejects a tampered method (signature base mismatch)', async () => { - const r = await verify({ method: 'GET' }); - expect(r).toEqual({ ok: false, reason: 'signature_invalid' }); - }); - - it('rejects a tampered path', async () => { - const r = await verify({ path: '/admin' }); - expect(r).toEqual({ ok: false, reason: 'signature_invalid' }); - }); - - it('rejects a tampered authority', async () => { - const r = await verify({ authority: 'evil.com' }); - expect(r).toEqual({ ok: false, reason: 'signature_invalid' }); - }); - - it('rejects a swapped Agent-Identity value (token swap)', async () => { - const r = await verify({ agentIdentity: 'different.token.here' }); - expect(r).toEqual({ ok: false, reason: 'signature_invalid' }); - }); - - it('rejects when cnf.jwk is a different key (keyid mismatch)', async () => { - const { privateKey, publicKey } = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - const otherPriv = await exportJWK(privateKey); - const otherPub = await exportJWK(publicKey); - const s = await signMessage({ ...baseReq, privateJwk: otherPriv, publicJwk: otherPub, created: 1715400000, expires: 1715400060 }); - // present the wrong cnf (our original key): keyid in the sig won't match its thumbprint - const r = await verifyMessageSignature({ - ...baseReq, - signatureInput: s.signatureInput, - signature: s.signature, - cnfJwk: publicJwk, - now: 1715400010, - }); - expect(r).toEqual({ ok: false, reason: 'keyid_mismatch' }); - }); - - it('rejects an expired signature beyond skew', async () => { - const s = await roundTrip({ created: 1715400000, expires: 1715400060 }); - const r = await verify({ now: 1715400200 }, s); - expect(r).toEqual({ ok: false, reason: 'expired' }); - }); - - it('accepts an expired signature within skew tolerance', async () => { - const s = await roundTrip({ created: 1715400000, expires: 1715400060 }); - const r = await verify({ now: 1715400080, maxSkewSeconds: 30 }, s); - expect(r.ok).toBe(true); - }); - - it('rejects a created timestamp too far in the future', async () => { - const s = await roundTrip({ created: 1715400000 }); - const r = await verify({ now: 1715300000 }, s); - expect(r).toEqual({ ok: false, reason: 'created_in_future' }); - }); - - it('rejects when the AIP minimum components are not all covered', async () => { - const s = await roundTrip({ created: 1715400000, components: ['@method', '@authority'] }); - const r = await verify({}, s); - expect(r).toEqual({ ok: false, reason: 'missing_covered_component' }); - }); - - it('rejects a signature missing created (no enforceable time bound)', async () => { - // Hand-build a Signature-Input with neither created nor expires, signed over that exact base, so - // the missing-created branch fires before the byte verification. An unbounded PoP is replayable. - const { signature } = await signMessage({ ...baseReq, privateJwk, publicJwk, created: 1715400000 }); - const header = `ait=${'("@method" "@authority" "@path" "agent-identity")'};keyid="${thumbprint}";tag="agent-identity"`; - const r = await verify({ signatureInput: header }, { signatureInput: header, signature }); - expect(r).toEqual({ ok: false, reason: 'created_missing' }); - }); - - it('rejects a signature missing expires (replayable for the full AIT lifetime)', async () => { - // signMessage omits `expires` by default: exactly the spec-loose shape the hardening rejects. - const s = await signMessage({ ...baseReq, privateJwk, publicJwk, created: 1715400000 }); - const r = await verify({}, s); - expect(r).toEqual({ ok: false, reason: 'expires_missing' }); - }); - - it('rejects a signature whose declared window (expires - created) exceeds the 120s ceiling', async () => { - // created+expires alone only bound replay to whatever window the SIGNER chose; the HTTP-sig layer - // caps it at 120s. A 300s window (an attacker matching the AIT's 300s ceiling) is rejected even - // though created/expires are present and `now` sits inside the window. - const s = await roundTrip({ created: 1715400000, expires: 1715400300 }); // 300s > 120s - const r = await verify({ now: 1715400005 }, s); // well inside the declared window - expect(r).toEqual({ ok: false, reason: 'pop_window_too_long' }); - }); - - it('accepts a signature whose declared window equals the 120s ceiling (boundary)', async () => { - const s = await roundTrip({ created: 1715400000, expires: 1715400120 }); // exactly 120s - const r = await verify({ now: 1715400005 }, s); - expect(r.ok).toBe(true); - }); - - it('rejects a NEGATIVE window (expires before created)', async () => { - // A negative window would slip under the 120s cap (negative < 120) and, within skew, pass the - // created/expires clock checks: it must be rejected as a window violation. - const s = await roundTrip({ created: 1715400000, expires: 1715399990 }); - const r = await verify({ now: 1715400005 }, s); - expect(r).toEqual({ ok: false, reason: 'pop_window_too_long' }); - }); - - it('rejects an untagged sole member at verify (tag is required)', async () => { - const s = await roundTrip({ created: 1715400000 }); - const stripped = s.signatureInput.replace(';tag="agent-identity"', ''); - const r = await verify({ signatureInput: stripped }, { signatureInput: stripped, signature: s.signature }); - expect(r).toEqual({ ok: false, reason: 'no_aip_signature' }); - }); - - it('verifies a valid 60s-window PoP (the first-party pay signer shape)', async () => { - // pay signs created + expires = created + 60. This must still verify post-hardening. - const s = await roundTrip({ created: 1715400000, expires: 1715400060 }); - const r = await verify({ now: 1715400010 }, s); - expect(r.ok).toBe(true); - }); - - it('rejects a non-ed25519 alg param', async () => { - const header = 'ait=("@method" "@authority" "@path" "agent-identity");created=1715400000;keyid="k";alg="rsa";tag="agent-identity"'; - const r = await verify({ signatureInput: header }, { signatureInput: header, signature: 'ait=:AA:' }); - expect(r).toEqual({ ok: false, reason: 'unsupported_alg' }); - }); - - it('accepts the JWS alg spelling "EdDSA" at the alg gate (case-insensitive)', async () => { - // ed25519 is the RFC 9421 label; EdDSA is the JWS label. We accept both, so an external signer - // that emits alg="EdDSA" must pass the alg gate and fail later for a real reason, not unsupported_alg. - // (created+expires present so the alg-spelling reaches the keyid check, not the time-bound gates.) - const header = 'ait=("@method" "@authority" "@path" "agent-identity");created=1715400000;expires=1715400060;keyid="k";alg="EdDSA";tag="agent-identity"'; - const r = await verify({ signatureInput: header }, { signatureInput: header, signature: 'ait=:AA:' }); - expect(r).toEqual({ ok: false, reason: 'keyid_mismatch' }); - }); - - it('returns no_aip_signature when no member matches', async () => { - const header = 'web-bot=("@authority");created=1;tag="web-bot-auth"'; - const r = await verify({ signatureInput: header }, { signatureInput: header, signature: 'web-bot=:AA:' }); - expect(r).toEqual({ ok: false, reason: 'no_aip_signature' }); - }); - - it('returns malformed_signature when the Signature dict lacks the selected label', async () => { - const s = await roundTrip({ created: 1715400000 }); - const r = await verify({ signature: 'wronglabel=:AQID:' }, { signatureInput: s.signatureInput, signature: 'wronglabel=:AQID:' }); - expect(r).toEqual({ ok: false, reason: 'malformed_signature' }); - }); - - it('rejects a P-256 cnf key with a typed failure (no throw)', async () => { - const r = await verify({ cnfJwk: { kty: 'EC', crv: 'P-256', x: 'abc', y: 'def' } as never }); - expect(r).toEqual({ ok: false, reason: 'unsupported_cnf_key' }); - }); - - it('rejects a malformed OKP cnf with non-string x without throwing', async () => { - // A trusted external issuer could mint an AIT whose cnf.jwk has a bad `x`; the thumbprint - // computation must not throw uncaught and crash the gate. - const r = await verify({ cnfJwk: { kty: 'OKP', crv: 'Ed25519', x: 123 } as never }); - expect(r).toEqual({ ok: false, reason: 'unsupported_cnf_key' }); - }); - - it('rejects an OKP cnf missing x without throwing', async () => { - const r = await verify({ cnfJwk: { kty: 'OKP', crv: 'Ed25519' } as never }); - expect(r).toEqual({ ok: false, reason: 'unsupported_cnf_key' }); - }); -}); diff --git a/tests/aip_jwks.test.ts b/tests/aip_jwks.test.ts deleted file mode 100644 index caee2b3..0000000 --- a/tests/aip_jwks.test.ts +++ /dev/null @@ -1,437 +0,0 @@ -import { generateKeyPair, exportJWK, type JWK } from 'jose'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { - DEFAULT_CACHE_SECONDS, - HARD_MAX_CACHE_SECONDS, - JwksCache, - REFETCH_COOLDOWN_MS, - canonicalizeIssuer, - resolveCacheSeconds, -} from '../src/aip/jwks'; - -let keyA: JWK; -let keyB: JWK; - -beforeAll(async () => { - const a = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - const b = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - keyA = { ...(await exportJWK(a.publicKey)), kid: 'key-A', use: 'sig' }; - keyB = { ...(await exportJWK(b.publicKey)), kid: 'key-B', use: 'sig' }; -}); - -describe('canonicalizeIssuer', () => { - it('lowercases scheme and host', () => { - expect(canonicalizeIssuer('HTTPS://Issuer.EXAMPLE')).toBe('https://issuer.example'); - }); - - it('drops a trailing slash', () => { - expect(canonicalizeIssuer('https://issuer.example/')).toBe('https://issuer.example'); - }); - - it('drops the default https port', () => { - expect(canonicalizeIssuer('https://issuer.example:443')).toBe('https://issuer.example'); - }); - - it('keeps a non-default port', () => { - expect(canonicalizeIssuer('https://issuer.example:8443')).toBe('https://issuer.example:8443'); - }); - - it('preserves a non-root path', () => { - expect(canonicalizeIssuer('https://idp.example.com/tenant1/')).toBe('https://idp.example.com/tenant1'); - }); - - it('returns null for non-URLs', () => { - expect(canonicalizeIssuer('not a url')).toBeNull(); - }); - - it('makes issuer.example and issuer.example/ compare equal', () => { - expect(canonicalizeIssuer('https://issuer.example')).toBe(canonicalizeIssuer('https://issuer.example/')); - }); -}); - -describe('resolveCacheSeconds', () => { - it('defaults when no header', () => { - expect(resolveCacheSeconds(null)).toBe(DEFAULT_CACHE_SECONDS); - }); - - it('honors a reasonable max-age', () => { - expect(resolveCacheSeconds('max-age=600')).toBe(600); - }); - - it('clamps to the hard cap', () => { - expect(resolveCacheSeconds('max-age=31536000')).toBe(HARD_MAX_CACHE_SECONDS); - }); - - it('falls back to default on no-store / no-cache', () => { - expect(resolveCacheSeconds('no-store')).toBe(DEFAULT_CACHE_SECONDS); - expect(resolveCacheSeconds('no-cache, max-age=999')).toBe(DEFAULT_CACHE_SECONDS); - }); - - it('falls back to default on a zero or junk max-age', () => { - expect(resolveCacheSeconds('max-age=0')).toBe(DEFAULT_CACHE_SECONDS); - expect(resolveCacheSeconds('max-age=abc')).toBe(DEFAULT_CACHE_SECONDS); - }); -}); - -const makeFetch = (keys: JWK[], opts: { cacheControl?: string; ok?: boolean; status?: number; body?: unknown } = {}) => { - const impl = vi.fn(async () => ({ - ok: opts.ok ?? true, - status: opts.status ?? 200, - headers: { get: (n: string) => (n.toLowerCase() === 'cache-control' ? opts.cacheControl ?? null : null) }, - json: async () => opts.body ?? { keys }, - })); - return impl; -}; - -const TRUSTED = ['https://issuer.example']; - -describe('JwksCache.isTrusted', () => { - it('matches canonicalized issuers', () => { - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl: makeFetch([keyA]) }); - expect(c.isTrusted('https://issuer.example/')).toBe(true); - expect(c.isTrusted('https://ISSUER.example')).toBe(true); - expect(c.isTrusted('https://evil.com')).toBe(false); - }); - - it('always trusts AgentScore’s canonical issuer, even with no/empty trustedIssuers', () => { - // The invariant must hold for EVERY consumer (raw adapters included), not just Checkout: - // a merchant can't accidentally fail to trust AgentScore-issued AITs. - const noList = new JwksCache({ fetchImpl: makeFetch([keyA]) }); - expect(noList.isTrusted('https://www.agentscore.com')).toBe(true); - expect(noList.isTrusted('https://www.agentscore.com/')).toBe(true); // canonicalized - expect(noList.isTrusted('https://issuer.example')).toBe(false); - - const emptyList = new JwksCache({ trustedIssuers: [], fetchImpl: makeFetch([keyA]) }); - expect(emptyList.isTrusted('https://www.agentscore.com')).toBe(true); - - const withExternal = new JwksCache({ trustedIssuers: ['https://issuer.example'], fetchImpl: makeFetch([keyA]) }); - expect(withExternal.isTrusted('https://www.agentscore.com')).toBe(true); // still implicit - expect(withExternal.isTrusted('https://issuer.example')).toBe(true); - }); -}); - -describe('JwksCache.getKey', () => { - it('rejects an untrusted issuer without fetching', async () => { - const fetchImpl = makeFetch([keyA]); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl }); - const r = await c.getKey('https://evil.com', 'key-A'); - expect(r).toEqual({ ok: false, reason: 'untrusted_issuer' }); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it('rejects an http (insecure) trusted issuer', async () => { - const c = new JwksCache({ trustedIssuers: ['http://issuer.example'], fetchImpl: makeFetch([keyA]) }); - const r = await c.getKey('http://issuer.example', 'key-A'); - expect(r).toEqual({ ok: false, reason: 'insecure_issuer' }); - }); - - it('fetches and returns a key by kid', async () => { - const fetchImpl = makeFetch([keyA, keyB]); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl }); - const r = await c.getKey('https://issuer.example', 'key-B'); - expect(r.ok).toBe(true); - if (r.ok) { expect(r.key.kid).toBe('key-B'); } - expect(fetchImpl).toHaveBeenCalledOnce(); - expect(fetchImpl.mock.calls[0][0]).toBe('https://issuer.example/.well-known/agent-identity/jwks.json'); - }); - - it('serves a second lookup from cache (no second fetch)', async () => { - const fetchImpl = makeFetch([keyA]); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl }); - await c.getKey('https://issuer.example', 'key-A'); - await c.getKey('https://issuer.example', 'key-A'); - expect(fetchImpl).toHaveBeenCalledOnce(); - }); - - it('refetches once on a kid miss within the cache window (past the cooldown)', async () => { - // First fetch returns only key-A; after "rotation" the impl returns A+B. The kid-miss - // refetch is gated by REFETCH_COOLDOWN_MS, so advance the clock past it to observe the - // single rotation-pickup refetch (the cooldown behavior itself is pinned separately below). - let call = 0; - let nowMs = 1_000_000; - const fetchImpl = vi.fn(async () => { - call++; - return { - ok: true, - status: 200, - headers: { get: () => 'max-age=600' }, - json: async () => ({ keys: call === 1 ? [keyA] : [keyA, keyB] }), - }; - }); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl, now: () => nowMs }); - await c.getKey('https://issuer.example', 'key-A'); // populates cache (call 1) - nowMs += 30_001; // past the 30s refetch cooldown, still inside the 600s cache window - const r = await c.getKey('https://issuer.example', 'key-B'); // miss → forced refetch (call 2) - expect(r.ok).toBe(true); - expect(fetchImpl).toHaveBeenCalledTimes(2); - }); - - it('returns key_not_found when the kid is absent after refresh', async () => { - const fetchImpl = makeFetch([keyA]); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl }); - const r = await c.getKey('https://issuer.example', 'nonexistent'); - expect(r).toEqual({ ok: false, reason: 'key_not_found' }); - }); - - // --- refetch-amplification / DoS guard (REFETCH_COOLDOWN_MS) --------------------------------- - - it('does NOT refetch on a kid miss within the cooldown: a flood of unknown kids costs 1 fetch', async () => { - // Canonical issuer is always trusted and kid/iss are read pre-verify, so an unauthenticated - // attacker could otherwise force one JWKS GET per unknown-kid token. The cooldown caps it. - let nowMs = 1_000_000; - const fetchImpl = makeFetch([keyA], { cacheControl: 'max-age=600' }); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl, now: () => nowMs }); - await c.getKey('https://issuer.example', 'key-A'); // populates cache + stamps cooldown (call 1) - // Stream many distinct unknown kids well inside the cooldown window. - for (let i = 0; i < 50; i++) { - nowMs += 100; // 5s total elapsed: still < 30s cooldown - const r = await c.getKey('https://issuer.example', `attacker-kid-${i}`); - expect(r).toEqual({ ok: false, reason: 'key_not_found' }); - } - expect(fetchImpl).toHaveBeenCalledTimes(1); // no amplification - }); - - it('a repeat lookup for a known-bad kid short-circuits within the cooldown', async () => { - let nowMs = 1_000_000; - const fetchImpl = makeFetch([keyA], { cacheControl: 'max-age=600' }); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl, now: () => nowMs }); - await c.getKey('https://issuer.example', 'key-A'); // call 1 - nowMs += 1_000; - const r1 = await c.getKey('https://issuer.example', 'ghost'); // memoized as negative, no fetch - const r2 = await c.getKey('https://issuer.example', 'ghost'); // short-circuits via memo - expect(r1).toEqual({ ok: false, reason: 'key_not_found' }); - expect(r2).toEqual({ ok: false, reason: 'key_not_found' }); - expect(fetchImpl).toHaveBeenCalledTimes(1); - }); - - it('allows one rotation-pickup refetch once the cooldown elapses', async () => { - // After the cooldown a single kid-miss may refetch (rotation may have published the key); - // the new fetch re-stamps the cooldown so the window reopens, not a permanent suppression. - let call = 0; - let nowMs = 1_000_000; - const fetchImpl = vi.fn(async () => { - call++; - return { - ok: true, - status: 200, - headers: { get: () => 'max-age=600' }, - json: async () => ({ keys: call === 1 ? [keyA] : [keyA, keyB] }), - }; - }); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl, now: () => nowMs }); - await c.getKey('https://issuer.example', 'key-A'); // call 1, cooldown until now+30s - nowMs += 5_000; - expect(await c.getKey('https://issuer.example', 'key-B')).toMatchObject({ ok: false }); // suppressed - expect(fetchImpl).toHaveBeenCalledTimes(1); - nowMs += REFETCH_COOLDOWN_MS; // past the cooldown - const r = await c.getKey('https://issuer.example', 'key-B'); // now refetches → finds key-B - expect(r.ok).toBe(true); - expect(fetchImpl).toHaveBeenCalledTimes(2); - }); - - it('a rotated-in kid resolves once the cooldown elapses (fresh fetch picks it up)', async () => { - let call = 0; - let nowMs = 1_000_000; - const fetchImpl = vi.fn(async () => { - call++; - return { - ok: true, - status: 200, - headers: { get: () => 'max-age=600' }, - json: async () => ({ keys: call === 1 ? [keyA] : [keyA, keyB] }), - }; - }); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl, now: () => nowMs }); - await c.getKey('https://issuer.example', 'key-A'); // call 1 - nowMs += 1_000; - await c.getKey('https://issuer.example', 'key-B'); // suppressed by cooldown, no fetch - nowMs += REFETCH_COOLDOWN_MS; // cooldown elapsed - const r = await c.getKey('https://issuer.example', 'key-B'); // refetch clears memo, finds key-B - expect(r.ok).toBe(true); - if (r.ok) { expect(r.key.kid).toBe('key-B'); } - expect(fetchImpl).toHaveBeenCalledTimes(2); - }); - - it('re-fetches after the cache expires (hard clock advance)', async () => { - let nowMs = 1_000_000; - const fetchImpl = makeFetch([keyA], { cacheControl: 'max-age=300' }); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl, now: () => nowMs }); - await c.getKey('https://issuer.example', 'key-A'); - nowMs += 301_000; // past the 300s window - await c.getKey('https://issuer.example', 'key-A'); - expect(fetchImpl).toHaveBeenCalledTimes(2); - }); - - it('selects the sole key when the token header has no kid', async () => { - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl: makeFetch([keyA]) }); - const r = await c.getKey('https://issuer.example', undefined); - expect(r.ok).toBe(true); - }); - - it('refuses a no-kid lookup when multiple keys exist (ambiguous)', async () => { - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl: makeFetch([keyA, keyB]) }); - const r = await c.getKey('https://issuer.example', undefined); - expect(r).toEqual({ ok: false, reason: 'key_not_found' }); - }); - - it('ignores non-signing (use != sig) keys', async () => { - const encKey = { ...keyA, kid: 'key-A', use: 'enc' }; - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl: makeFetch([encKey]) }); - const r = await c.getKey('https://issuer.example', 'key-A'); - expect(r).toEqual({ ok: false, reason: 'key_not_found' }); - }); - - it('reports fetch_failed on a non-ok response', async () => { - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl: makeFetch([keyA], { ok: false, status: 503 }) }); - const r = await c.getKey('https://issuer.example', 'key-A'); - expect(r).toEqual({ ok: false, reason: 'fetch_failed' }); - }); - - it('reports malformed_jwks when the body has no keys array', async () => { - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl: makeFetch([], { body: { notkeys: 1 } }) }); - const r = await c.getKey('https://issuer.example', 'key-A'); - expect(r).toEqual({ ok: false, reason: 'malformed_jwks' }); - }); - - it('reports fetch_failed when fetch throws', async () => { - const fetchImpl = vi.fn(async () => { throw new Error('network'); }); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl }); - const r = await c.getKey('https://issuer.example', 'key-A'); - expect(r).toEqual({ ok: false, reason: 'fetch_failed' }); - }); - - // --- failure cooldown (negative cache for cold/erroring issuers) ----------------------------- - - it('sequential failures within the cooldown perform exactly ONE upstream fetch', async () => { - // An erroring issuer must not be re-fetched per request: the failed attempt stamps the same - // per-issuer cooldown a success does, so a stream of tokens against a down/cold issuer costs - // one GET per cooldown window: not one per token. - let nowMs = 1_000_000; - const fetchImpl = vi.fn(async () => { throw new Error('network'); }); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl, now: () => nowMs }); - expect(await c.getKey('https://issuer.example', 'key-A')).toEqual({ ok: false, reason: 'fetch_failed' }); - for (let i = 0; i < 50; i++) { - nowMs += 100; // 5s total elapsed: still < 30s cooldown - const r = await c.getKey('https://issuer.example', `kid-${i}`); - expect(r).toEqual({ ok: false, reason: 'fetch_failed' }); - } - expect(fetchImpl).toHaveBeenCalledTimes(1); - }); - - it('a non-2xx JWKS response also stamps the cooldown (one fetch per window)', async () => { - let nowMs = 1_000_000; - const fetchImpl = makeFetch([], { ok: false, status: 503 }); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl, now: () => nowMs }); - expect(await c.getKey('https://issuer.example', 'key-A')).toEqual({ ok: false, reason: 'fetch_failed' }); - nowMs += 5_000; - expect(await c.getKey('https://issuer.example', 'key-A')).toEqual({ ok: false, reason: 'fetch_failed' }); - expect(fetchImpl).toHaveBeenCalledTimes(1); - }); - - it('a malformed JWKS body also stamps the cooldown and replays its reason', async () => { - let nowMs = 1_000_000; - const fetchImpl = makeFetch([], { body: { notkeys: 1 } }); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl, now: () => nowMs }); - expect(await c.getKey('https://issuer.example', 'key-A')).toEqual({ ok: false, reason: 'malformed_jwks' }); - nowMs += 5_000; - expect(await c.getKey('https://issuer.example', 'key-A')).toEqual({ ok: false, reason: 'malformed_jwks' }); - expect(fetchImpl).toHaveBeenCalledTimes(1); - }); - - it('retries the upstream fetch once the failure cooldown elapses', async () => { - let call = 0; - let nowMs = 1_000_000; - const fetchImpl = vi.fn(async () => { - call++; - if (call === 1) { throw new Error('network'); } - return { - ok: true, - status: 200, - headers: { get: () => 'max-age=600' }, - json: async () => ({ keys: [keyA] }), - }; - }); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl, now: () => nowMs }); - expect(await c.getKey('https://issuer.example', 'key-A')).toEqual({ ok: false, reason: 'fetch_failed' }); - nowMs += REFETCH_COOLDOWN_MS + 1; // past the failure cooldown - const r = await c.getKey('https://issuer.example', 'key-A'); // retried → recovers - expect(r.ok).toBe(true); - expect(fetchImpl).toHaveBeenCalledTimes(2); - }); - - it('a failed kid-miss refetch does not nuke a still-fresh cached key set', async () => { - // Fresh cache for key-A; an unknown-kid lookup past the cooldown forces a refetch that FAILS. - // The failure stamps a new cooldown but must preserve the fresh keys: key-A keeps resolving. - let call = 0; - let nowMs = 1_000_000; - const fetchImpl = vi.fn(async () => { - call++; - if (call > 1) { throw new Error('network'); } - return { - ok: true, - status: 200, - headers: { get: () => 'max-age=600' }, - json: async () => ({ keys: [keyA] }), - }; - }); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl, now: () => nowMs }); - expect((await c.getKey('https://issuer.example', 'key-A')).ok).toBe(true); // call 1 - nowMs += REFETCH_COOLDOWN_MS + 1; // past the cooldown, still inside the 600s window - expect(await c.getKey('https://issuer.example', 'ghost')).toEqual({ ok: false, reason: 'fetch_failed' }); // call 2 fails - const r = await c.getKey('https://issuer.example', 'key-A'); // fresh keys preserved - expect(r.ok).toBe(true); - expect(fetchImpl).toHaveBeenCalledTimes(2); - }); - - // --- single-flight: coalesce CONCURRENT refreshes to one upstream fetch --------------------- - - it('coalesces a concurrent distinct-kid burst on a cold cache to a SINGLE fetch', async () => { - // The cooldown only suppresses SEQUENTIAL refetches. Without single-flight, a concurrent burst - // of distinct unknown kids on a COLD/expired cache each enters refresh() before any has - // populated the cache → N parallel JWKS GETs (refetch amplification against the issuer). A - // deferred fetch impl keeps the first GET in-flight until all 200 callers have queued, so we - // observe exactly one upstream call. - let resolveFetch: (() => void) | undefined; - const gate = new Promise((res) => { resolveFetch = res; }); - const fetchImpl = vi.fn(async () => { - await gate; // hold the fetch open until every concurrent caller has entered refresh() - return { - ok: true, - status: 200, - headers: { get: () => 'max-age=600' }, - json: async () => ({ keys: [keyA, keyB] }), - }; - }); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl }); - - // Fire 200 concurrent lookups, each with a DISTINCT kid (most unknown), on the cold cache. - const lookups = Array.from({ length: 200 }, (_, i) => - c.getKey('https://issuer.example', i === 0 ? 'key-A' : i === 1 ? 'key-B' : `kid-${i}`), - ); - // Let all 200 register their await on the shared in-flight promise, then release the fetch. - await Promise.resolve(); - resolveFetch?.(); - const results = await Promise.all(lookups); - - expect(fetchImpl).toHaveBeenCalledTimes(1); // single upstream fetch for the whole burst - // Each caller still selects independently from the shared key set: the two known kids resolve, - // the rest report key_not_found: proving coalescing didn't cross-contaminate verdicts. - expect(results[0]).toMatchObject({ ok: true }); - expect(results[1]).toMatchObject({ ok: true }); - expect(results[2]).toEqual({ ok: false, reason: 'key_not_found' }); - if (results[0].ok) { expect(results[0].key.kid).toBe('key-A'); } - if (results[1].ok) { expect(results[1].key.kid).toBe('key-B'); } - }); - - it('clears the in-flight entry so a later cold/expired lookup can refetch', async () => { - // Single-flight must not pin the first fetch forever: once it settles, the entry is cleared and - // the next expired-window lookup issues a fresh GET. - let nowMs = 1_000_000; - const fetchImpl = makeFetch([keyA], { cacheControl: 'max-age=300' }); - const c = new JwksCache({ trustedIssuers: TRUSTED, fetchImpl, now: () => nowMs }); - await c.getKey('https://issuer.example', 'key-A'); // fetch 1, in-flight cleared on settle - nowMs += 301_000; // expire the cache window - await c.getKey('https://issuer.example', 'key-A'); // fetch 2 (entry was not pinned) - expect(fetchImpl).toHaveBeenCalledTimes(2); - }); -}); diff --git a/tests/aip_presentation.test.ts b/tests/aip_presentation.test.ts deleted file mode 100644 index ea86c23..0000000 --- a/tests/aip_presentation.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * AIP presentation surfaces: agent_memory, llms.txt, skill.md, and OpenAPI security schemes all - * advertise the Agent Identity Token path when the merchant accepts AIP. AgentScore's own issuer is - * always trusted, so the path shows up even with no external issuers configured. - */ -import { describe, expect, it } from 'vitest'; -import { AGENTSCORE_CANONICAL_ISSUER } from '../src/aip/jwks'; -import { firstEncounterAgentMemory } from '../src/challenge/agent_memory'; -import { buildAipTrustedIssuers } from '../src/checkout'; -import { buildAgentMemoryHint } from '../src/core'; -import { llmsTxtIdentitySection } from '../src/discovery/llms_txt'; -import { agentscoreSecuritySchemes } from '../src/discovery/openapi'; -import { buildSkillMd } from '../src/discovery/skill_md'; - -describe('buildAipTrustedIssuers', () => { - it('always includes the canonical AgentScore issuer', () => { - expect(buildAipTrustedIssuers()).toEqual([AGENTSCORE_CANONICAL_ISSUER]); - expect(buildAipTrustedIssuers([])).toEqual([AGENTSCORE_CANONICAL_ISSUER]); - }); - it('appends externals and de-dupes the canonical issuer', () => { - const out = buildAipTrustedIssuers(['https://issuer.example', 'https://www.agentscore.com/']); - expect(out).toContain(AGENTSCORE_CANONICAL_ISSUER); - expect(out).toContain('https://issuer.example'); - expect(out.filter((i) => i === AGENTSCORE_CANONICAL_ISSUER)).toHaveLength(1); - }); -}); - -describe('agent_memory AIP path', () => { - it('omits agent_identity when no aip issuers are passed', () => { - const hint = buildAgentMemoryHint(); - expect(hint.identity_paths.agent_identity).toBeUndefined(); - expect(hint.aip_trusted_issuers).toBeUndefined(); - }); - it('advertises agent_identity + aip_trusted_issuers when aip is accepted (canonical-only)', () => { - const hint = buildAgentMemoryHint(buildAipTrustedIssuers()); - expect(hint.identity_paths.agent_identity).toMatch(/Agent-Identity/); - expect(hint.aip_trusted_issuers).toEqual([AGENTSCORE_CANONICAL_ISSUER]); - }); - it('firstEncounterAgentMemory forwards the aip issuers', () => { - const hint = firstEncounterAgentMemory({ firstEncounter: true, aipTrustedIssuers: buildAipTrustedIssuers() }); - expect(hint?.identity_paths.agent_identity).toMatch(/RFC 9421/); - expect(hint?.aip_trusted_issuers).toEqual([AGENTSCORE_CANONICAL_ISSUER]); - }); - it('firstEncounterAgentMemory without aip stays wallet/operator only', () => { - const hint = firstEncounterAgentMemory({ firstEncounter: true }); - expect(hint?.identity_paths.agent_identity).toBeUndefined(); - }); -}); - -describe('llms.txt identity section', () => { - it('adds the Agent-Identity bullet when aip is true', () => { - const out = llmsTxtIdentitySection({ agentscore: true, aip: true }); - expect(out).toMatch(/Agent-Identity/); - expect(out).toMatch(/RFC 9421/); - }); - it('omits the AIP bullet by default', () => { - const out = llmsTxtIdentitySection({ agentscore: true }); - expect(out).not.toMatch(/Agent-Identity/); - expect(out).toMatch(/X-Operator-Token/); - }); -}); - -describe('skill.md identity section', () => { - const base = { - name: 'test-merchant', - description: 'Test merchant skill for AIP presentation.', - homepage: 'https://merchant.example', - merchantName: 'Test Merchant', - acceptedRails: ['tempo_mpp' as const], - endpoints: [{ method: 'POST' as const, path: '/purchase', authRequired: true, description: 'Buy' }], - triggers: ['buy something'], - }; - it('documents the AIT path when identity.aip is set', () => { - const md = buildSkillMd({ ...base, identity: { kycRequired: true, aip: true } }); - expect(md).toMatch(/Agent Identity Token/); - expect(md).toMatch(/Agent-Identity/); - }); - it('does not mention AIP when identity.aip is unset', () => { - const md = buildSkillMd({ ...base, identity: { kycRequired: true } }); - expect(md).not.toMatch(/Agent-Identity/); - }); -}); - -describe('openapi security schemes', () => { - it('includes the AgentIdentity scheme when aip is set', () => { - const schemes = agentscoreSecuritySchemes({ aip: true }); - expect(schemes.AgentIdentity).toMatchObject({ type: 'apiKey', in: 'header', name: 'Agent-Identity' }); - }); - it('omits the AgentIdentity scheme by default', () => { - const schemes = agentscoreSecuritySchemes(); - expect(schemes.AgentIdentity).toBeUndefined(); - expect(Object.keys(schemes)).toEqual(['OperatorToken', 'WalletAddress', 'siwx']); - }); -}); diff --git a/tests/aip_request.test.ts b/tests/aip_request.test.ts deleted file mode 100644 index 5659aff..0000000 --- a/tests/aip_request.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildVerifyContextFromParts, buildVerifyContextFromRequest, hasAgentIdentityHeader } from '../src/aip/request'; - -const make = (headers: Record, url = 'https://wine-merchant.com/checkout', method = 'POST'): Request => - new Request(url, { method, headers }); - -describe('buildVerifyContextFromRequest', () => { - it('extracts method, path, and authority from the Host header', () => { - const ctx = buildVerifyContextFromRequest( - make({ host: 'wine-merchant.com', 'agent-identity': 'jwt.a.b', 'signature-input': 'si', signature: 'sig' }), - ); - expect(ctx.method).toBe('POST'); - expect(ctx.path).toBe('/checkout'); - expect(ctx.authority).toBe('wine-merchant.com'); - expect(ctx.agentIdentityHeaders).toEqual(['jwt.a.b']); - expect(ctx.signatureInput).toBe('si'); - expect(ctx.signature).toBe('sig'); - }); - - it('falls back to the URL host when no Host header is set', () => { - const ctx = buildVerifyContextFromRequest(make({ 'agent-identity': 'x.y.z' }, 'https://api.example.com:3003/path')); - expect(ctx.authority).toBe('api.example.com:3003'); - expect(ctx.path).toBe('/path'); - }); - - it('drops the query string from the path', () => { - const ctx = buildVerifyContextFromRequest(make({ 'agent-identity': 'a.b.c' }, 'https://m.com/wines?sort=price')); - expect(ctx.path).toBe('/wines'); - }); - - it('returns empty headers when none present', () => { - const ctx = buildVerifyContextFromRequest(make({})); - expect(ctx.agentIdentityHeaders).toEqual([]); - expect(ctx.signatureInput).toBeNull(); - expect(ctx.signature).toBeNull(); - }); - - it('splits multiple comma-folded Agent-Identity headers into separate tokens', () => { - // Fetch Headers folds repeated headers into one comma-joined value. - const h = new Headers(); - h.append('agent-identity', 'aaa.bbb.ccc'); - h.append('agent-identity', 'ddd.eee.fff'); - const ctx = buildVerifyContextFromRequest(new Request('https://m.com/x', { method: 'POST', headers: h })); - expect(ctx.agentIdentityHeaders).toEqual(['aaa.bbb.ccc', 'ddd.eee.fff']); - }); -}); - -describe('hasAgentIdentityHeader', () => { - it('is true when an Agent-Identity header is present', () => { - expect(hasAgentIdentityHeader(make({ 'agent-identity': 'a.b.c' }))).toBe(true); - }); - - it('is false when absent', () => { - expect(hasAgentIdentityHeader(make({}))).toBe(false); - }); - - it('is false for an empty header value', () => { - expect(hasAgentIdentityHeader(make({ 'agent-identity': '' }))).toBe(false); - }); -}); - -describe('buildVerifyContextFromParts: @path derivation matches the signer', () => { - const parts = (url: string, host = 'wine.example') => buildVerifyContextFromParts({ - method: 'POST', url, headers: { host, 'agent-identity': 'a.b.c', 'signature-input': 'si', signature: 'sig' }, - }); - - it('derives the path from an origin-form target and drops the query', () => { - expect(parts('/checkout?order=42').path).toBe('/checkout'); - }); - - it('preserves a leading // in the path (regression: must equal URL.pathname, not protocol-relative)', () => { - // The signer signs `new URL('https://wine.example//promo//x').pathname` === '//promo//x'; - // the old `new URL('//promo//x', base)` mis-read it as authority and produced '//x'. - expect(parts('//promo//x').path).toBe('//promo//x'); - expect(new URL('https://wine.example//promo//x').pathname).toBe('//promo//x'); // pin the contract - }); - - it('normalizes dot-segments identically to the signer', () => { - expect(parts('/a/../b').path).toBe(new URL('https://wine.example/a/../b').pathname); - }); - - it('handles an absolute URL target', () => { - expect(parts('https://wine.example/wines/pinot?x=1').path).toBe('/wines/pinot'); - }); -}); diff --git a/tests/aip_types.test.ts b/tests/aip_types.test.ts deleted file mode 100644 index 977c7f4..0000000 --- a/tests/aip_types.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { isAitShape, validateAitPayload, type AitPayload } from '../src/aip/types'; - -const validPayload: AitPayload = { - aip_version: '0.1', - iss: 'https://issuer.example', - sub: 'user_abc123', - iat: 1715400000, - exp: 1715400300, - cnf: { jwk: { kty: 'OKP', crv: 'Ed25519', x: 'abc' } }, - agent: { provider: 'anthropic', instance: 'session-xyz' }, - trust_level: 'human_present', -}; - -describe('isAitShape', () => { - it('is true when cnf + agent are present objects', () => { - expect(isAitShape(validPayload)).toBe(true); - }); - - it('is false without cnf', () => { - const { cnf, ...rest } = validPayload; - void cnf; - expect(isAitShape(rest)).toBe(false); - }); - - it('is false without agent', () => { - const { agent, ...rest } = validPayload; - void agent; - expect(isAitShape(rest)).toBe(false); - }); - - it('is false for non-objects', () => { - expect(isAitShape(null)).toBe(false); - expect(isAitShape('x')).toBe(false); - expect(isAitShape([])).toBe(false); - }); -}); - -describe('validateAitPayload: required claims', () => { - it('accepts a well-formed payload', () => { - const r = validateAitPayload(validPayload); - expect(r.ok).toBe(true); - }); - - it.each([ - ['aip_version', 'missing_aip_version'], - ['iss', 'missing_iss'], - ['sub', 'missing_sub'], - ['iat', 'missing_iat'], - ['exp', 'missing_exp'], - ['cnf', 'missing_cnf'], - ['agent', 'missing_agent_provider'], - ] as const)('rejects a payload missing %s', (field, reason) => { - const clone: Record = { ...validPayload }; - delete clone[field]; - const r = validateAitPayload(clone); - expect(r).toEqual({ ok: false, reason }); - }); - - it('rejects a non-object', () => { - expect(validateAitPayload(null)).toEqual({ ok: false, reason: 'not_an_object' }); - expect(validateAitPayload('jwt')).toEqual({ ok: false, reason: 'not_an_object' }); - }); - - it('rejects a cnf without a jwk', () => { - const r = validateAitPayload({ ...validPayload, cnf: { notjwk: true } }); - expect(r).toEqual({ ok: false, reason: 'missing_cnf' }); - }); - - it('rejects an agent without provider', () => { - const r = validateAitPayload({ ...validPayload, agent: { instance: 'x' } }); - expect(r).toEqual({ ok: false, reason: 'missing_agent_provider' }); - }); - - it('rejects a non-numeric iat/exp', () => { - expect(validateAitPayload({ ...validPayload, iat: '1715400000' })).toEqual({ ok: false, reason: 'missing_iat' }); - expect(validateAitPayload({ ...validPayload, exp: null })).toEqual({ ok: false, reason: 'missing_exp' }); - }); -}); - -describe('validateAitPayload: human_confirmed requires auth.amr', () => { - it('rejects human_confirmed with no auth at all', () => { - const r = validateAitPayload({ ...validPayload, trust_level: 'human_confirmed' }); - expect(r).toEqual({ ok: false, reason: 'human_confirmed_without_amr' }); - }); - - it('rejects human_confirmed with an empty amr array', () => { - const r = validateAitPayload({ ...validPayload, trust_level: 'human_confirmed', auth: { amr: [], time: 1 } }); - expect(r).toEqual({ ok: false, reason: 'human_confirmed_without_amr' }); - }); - - it('accepts human_confirmed with at least one amr value', () => { - const r = validateAitPayload({ ...validPayload, trust_level: 'human_confirmed', auth: { amr: ['face'], time: 1715399900 } }); - expect(r.ok).toBe(true); - }); - - it('does not require amr for autonomous or human_present', () => { - expect(validateAitPayload({ ...validPayload, trust_level: 'autonomous' }).ok).toBe(true); - expect(validateAitPayload({ ...validPayload, trust_level: 'human_present' }).ok).toBe(true); - }); -}); - -describe('validateAitPayload: extension claims pass through', () => { - it('preserves identity + payment extension claims on success', () => { - const withExtensions: AitPayload = { - ...validPayload, - identity: { - email: 'b@example.com', - email_verified: true, - age_over_21: true, - jurisdiction: 'US-CA', - sanctions_clear: true, - sanctions_providers: ['ofac_sdn', 'opensanctions'], - linked_wallets: [{ address: '0xabc', network: 'evm' }], - merchants_paid: 4, - }, - payment: { signer: { address: '0xabc', network: 'evm', match: 'linked_operator' } }, - }; - const r = validateAitPayload(withExtensions); - expect(r.ok).toBe(true); - if (r.ok) { - expect(r.payload.identity?.jurisdiction).toBe('US-CA'); - expect(r.payload.payment?.signer?.match).toBe('linked_operator'); - } - }); -}); diff --git a/tests/aip_verify.test.ts b/tests/aip_verify.test.ts deleted file mode 100644 index aaa2d9d..0000000 --- a/tests/aip_verify.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { SignJWT, calculateJwkThumbprint, exportJWK, generateKeyPair, importJWK, type JWK } from 'jose'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { signMessage } from '../src/aip/http-signature'; -import { JwksCache } from '../src/aip/jwks'; -import { verifyAit, type VerifyRequestContext } from '../src/aip/verify'; - -const ISS = 'https://issuer.example'; -const KID = 'partner-key-2026-05'; - -// IdP signing keypair -let idpPrivate: CryptoKey; -let idpPublicJwk: JWK; -// Agent (cnf) keypair -let agentPrivateJwk: JWK; -let agentPublicJwk: JWK; - -beforeAll(async () => { - const idp = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - idpPrivate = idp.privateKey; - idpPublicJwk = { ...(await exportJWK(idp.publicKey)), kid: KID, use: 'sig', alg: 'EdDSA' }; - - const agent = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - agentPrivateJwk = await exportJWK(agent.privateKey); - agentPublicJwk = await exportJWK(agent.publicKey); -}); - -interface MintOpts { - iss?: string; - kid?: string; - iat?: number; - exp?: number; - trustLevel?: string; - amr?: string[]; - cnfJwk?: JWK; - omitAgent?: boolean; -} - -const mintAit = async (o: MintOpts = {}): Promise => { - const payload: Record = { - aip_version: '0.1', - sub: 'user_abc123', - cnf: { jwk: o.cnfJwk ?? agentPublicJwk }, - trust_level: o.trustLevel ?? 'human_present', - identity: { email: 'b@example.com', email_verified: true }, - }; - if (!o.omitAgent) { payload.agent = { provider: 'anthropic', instance: 'sess-1' }; } - if (o.amr) { payload.auth = { amr: o.amr, time: 1715399900 }; } - - return new SignJWT(payload) - .setProtectedHeader({ alg: 'EdDSA', typ: 'jwt', kid: o.kid ?? KID }) - .setIssuer(o.iss ?? ISS) - .setIssuedAt(o.iat ?? 1715400000) - .setExpirationTime(o.exp ?? 1715400300) - .sign(idpPrivate); -}; - -const jwksFor = (publicJwk: JWK) => - new JwksCache({ - trustedIssuers: [ISS], - fetchImpl: vi.fn(async () => ({ - ok: true, - status: 200, - headers: { get: () => 'max-age=300' }, - json: async () => ({ keys: [publicJwk] }), - })), - }); - -const REQ = { method: 'POST', authority: 'wine-merchant.com', path: '/checkout' }; - -const signedCtx = async (token: string, signWith = agentPrivateJwk, signPub = agentPublicJwk, created = 1715400010): Promise => { - const { signatureInput, signature } = await signMessage({ - ...REQ, - agentIdentity: token, - privateJwk: signWith, - publicJwk: signPub, - created, - // The PoP verifier now REQUIRES `expires` (replay-window hardening); sign a 60s window like pay. - expires: created + 60, - }); - return { ...REQ, agentIdentityHeaders: [token], signatureInput, signature }; -}; - -const NOW = 1715400020; - -describe('verifyAit: happy path', () => { - it('verifies a well-formed, signed AIT end to end', async () => { - const token = await mintAit(); - const ctx = await signedCtx(token); - const r = await verifyAit(ctx, { jwks: jwksFor(idpPublicJwk), now: NOW }); - expect(r.ok).toBe(true); - if (r.ok) { - expect(r.ait.iss).toBe(ISS); - expect(r.ait.payload.identity?.email).toBe('b@example.com'); - } - }); - - it('verifies a human_confirmed AIT that carries auth.amr', async () => { - const token = await mintAit({ trustLevel: 'human_confirmed', amr: ['face'] }); - const ctx = await signedCtx(token); - const r = await verifyAit(ctx, { jwks: jwksFor(idpPublicJwk), now: NOW }); - expect(r.ok).toBe(true); - }); -}); - -describe('verifyAit: token presence', () => { - it('returns no_token when no Agent-Identity header', async () => { - const r = await verifyAit( - { ...REQ, agentIdentityHeaders: [], signatureInput: 'x', signature: 'y' }, - { jwks: jwksFor(idpPublicJwk), now: NOW }, - ); - expect(r).toEqual({ ok: false, reason: 'no_token' }); - }); - - it('returns pop_signature_missing when the RFC 9421 headers are absent', async () => { - const token = await mintAit(); - const r = await verifyAit( - { ...REQ, agentIdentityHeaders: [token], signatureInput: null, signature: null }, - { jwks: jwksFor(idpPublicJwk), now: NOW }, - ); - expect(r).toEqual({ ok: false, reason: 'pop_signature_missing' }); - }); - - it('accepts a Bearer-prefixed Agent-Identity header: the signed component is the bare JWT', async () => { - const token = await mintAit(); - // Sign over the BARE AIT (Bearer is transport the verifier strips before the crypto), then - // present it WITH a Bearer prefix; edge and API both reconstruct over the bare JWT. - const { signatureInput, signature } = await signMessage({ ...REQ, agentIdentity: token, privateJwk: agentPrivateJwk, publicJwk: agentPublicJwk, created: 1715400010, expires: 1715400070 }); - const r = await verifyAit({ ...REQ, agentIdentityHeaders: [`Bearer ${token}`], signatureInput, signature }, { jwks: jwksFor(idpPublicJwk), now: NOW }); - expect(r.ok).toBe(true); - }); -}); - -describe('verifyAit: issuer + key', () => { - it('rejects an untrusted issuer', async () => { - const token = await mintAit({ iss: 'https://evil.com' }); - const ctx = await signedCtx(token); - const r = await verifyAit(ctx, { jwks: jwksFor(idpPublicJwk), now: NOW }); - expect(r).toEqual({ ok: false, reason: 'untrusted_issuer' }); - }); - - it('reports key_unavailable when JWKS lacks the kid', async () => { - const token = await mintAit({ kid: 'unknown-kid' }); - const ctx = await signedCtx(token); - const r = await verifyAit(ctx, { jwks: jwksFor(idpPublicJwk), now: NOW }); - expect(r).toEqual({ ok: false, reason: 'key_unavailable' }); - }); -}); - -describe('verifyAit: signature + expiry', () => { - it('rejects an AIT signed by a different IdP key (idp_signature_invalid)', async () => { - // JWKS serves an unrelated key under the same kid → IdP sig fails. - const other = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - const otherPub = { ...(await exportJWK(other.publicKey)), kid: KID, use: 'sig', alg: 'EdDSA' }; - const token = await mintAit(); - const ctx = await signedCtx(token); - const r = await verifyAit(ctx, { jwks: jwksFor(otherPub), now: NOW }); - expect(r).toEqual({ ok: false, reason: 'idp_signature_invalid' }); - }); - - it('rejects an expired AIT', async () => { - const token = await mintAit({ iat: 1715300000, exp: 1715300300 }); - const ctx = await signedCtx(token, agentPrivateJwk, agentPublicJwk, 1715300010); - const r = await verifyAit(ctx, { jwks: jwksFor(idpPublicJwk), now: NOW }); - expect(r).toEqual({ ok: false, reason: 'expired_token' }); - }); - - it('rejects an AIT whose iat is in the future (spec step 5)', async () => { - // Not expired (exp ahead of now) and the PoP is fresh, but iat is well beyond now+skew. - const token = await mintAit({ iat: NOW + 1000, exp: NOW + 1300 }); - const ctx = await signedCtx(token); - const r = await verifyAit(ctx, { jwks: jwksFor(idpPublicJwk), now: NOW }); - expect(r).toEqual({ ok: false, reason: 'expired_token' }); - }); - - it('rejects an AIT with an absurdly long lifetime (defense-in-depth, exp-iat > max)', async () => { - // Valid window around NOW but a ~2h lifetime, well over the 300s default ceiling. - const token = await mintAit({ iat: NOW - 100, exp: NOW + 7200 }); - const ctx = await signedCtx(token); - const r = await verifyAit(ctx, { jwks: jwksFor(idpPublicJwk), now: NOW }); - expect(r).toEqual({ ok: false, reason: 'expired_token' }); - }); - - it('rejects an AIT whose lifetime exceeds the 300s edge ceiling (lowered from 3600)', async () => { - // A 600s-lifetime AIT (under the old 3600 default, over the new 300) is now rejected at the edge, - // matching the authoritative API verifier. The PoP is fresh and exp is ahead of now: only the - // exp-iat span is the problem. - const token = await mintAit({ iat: NOW - 10, exp: NOW + 590 }); // 600s lifetime > 300s - const ctx = await signedCtx(token); - const r = await verifyAit(ctx, { jwks: jwksFor(idpPublicJwk), now: NOW }); - expect(r).toEqual({ ok: false, reason: 'expired_token' }); - }); - - it('accepts an AIT at exactly the 300s lifetime ceiling (our own mint sits here)', async () => { - const token = await mintAit({ iat: NOW - 10, exp: NOW + 290 }); // exactly 300s - const ctx = await signedCtx(token); - const r = await verifyAit(ctx, { jwks: jwksFor(idpPublicJwk), now: NOW }); - expect(r.ok).toBe(true); - }); - - it('rejects an RS256-signed AIT even when the trusted IdP publishes the matching RSA key (alg allowlist, RFC 8725 §3.1)', async () => { - // Threat: a trusted IdP that publishes a non-Ed25519 `use:sig` key must NOT let an attacker - // present an RS256 token that verifies. The alg allowlist on jwtVerify pins EdDSA/ES256 - // regardless of what the token header claims or the resolved JWK supports. - const rsa = await generateKeyPair('RS256', { extractable: true }); - const rsaPub = { ...(await exportJWK(rsa.publicKey)), kid: KID, use: 'sig', alg: 'RS256' }; - const token = await new SignJWT({ - aip_version: '0.1', sub: 'user_abc123', cnf: { jwk: agentPublicJwk }, - agent: { provider: 'anthropic', instance: 'sess-1' }, trust_level: 'human_present', - identity: { email: 'b@example.com', email_verified: true }, - }) - .setProtectedHeader({ alg: 'RS256', typ: 'jwt', kid: KID }) - .setIssuer(ISS).setIssuedAt(1715400000).setExpirationTime(1715400300) - .sign(rsa.privateKey); - const ctx = await signedCtx(token); - const r = await verifyAit(ctx, { jwks: jwksFor(rsaPub), now: NOW }); - expect(r).toEqual({ ok: false, reason: 'idp_signature_invalid' }); - }); -}); - -describe('verifyAit: claim contract', () => { - it('rejects a token that is not AIT-shaped (no agent claim)', async () => { - const token = await mintAit({ omitAgent: true }); - const ctx = await signedCtx(token); - const r = await verifyAit(ctx, { jwks: jwksFor(idpPublicJwk), now: NOW }); - expect(r).toEqual({ ok: false, reason: 'malformed_token' }); - }); - - it('rejects human_confirmed without auth.amr (invalid_claims)', async () => { - const token = await mintAit({ trustLevel: 'human_confirmed' }); - const ctx = await signedCtx(token); - const r = await verifyAit(ctx, { jwks: jwksFor(idpPublicJwk), now: NOW }); - expect(r).toEqual({ ok: false, reason: 'invalid_claims' }); - }); -}); - -describe('verifyAit: proof of possession', () => { - it('rejects when the request is signed by a key other than cnf.jwk (pop_signature_invalid)', async () => { - // AIT binds agentPublicJwk, but we sign the request with a different key. - const other = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - const otherPriv = await exportJWK(other.privateKey); - const otherPub = await exportJWK(other.publicKey); - const token = await mintAit(); - const ctx = await signedCtx(token, otherPriv, otherPub); - const r = await verifyAit(ctx, { jwks: jwksFor(idpPublicJwk), now: NOW }); - expect(r).toEqual({ ok: false, reason: 'pop_signature_invalid' }); - }); - - it('rejects a request whose path was tampered after signing', async () => { - const token = await mintAit(); - const ctx = await signedCtx(token); - const tampered = { ...ctx, path: '/admin' }; - const r = await verifyAit(tampered, { jwks: jwksFor(idpPublicJwk), now: NOW }); - expect(r).toEqual({ ok: false, reason: 'pop_signature_invalid' }); - }); - - it('rejects (does not throw on) an AIT bound to a P-256 cnf key', async () => { - // The PoP verifier is Ed25519-only. A structurally-valid, JWT-valid AIT whose cnf is a P-256 - // EC key must return a typed failure (caught by the cnf-key-type guard), NOT crash the gate - // with an uncaught importJWK throw. Sign the request with the normal Ed25519 agent key: the - // request signature is irrelevant because the verifier rejects on the cnf key type first. - const ec = await generateKeyPair('ES256', { extractable: true }); - const ecPub = await exportJWK(ec.publicKey); - const token = await new SignJWT({ - aip_version: '0.1', sub: 'user_abc123', cnf: { jwk: ecPub }, - agent: { provider: 'anthropic', instance: 'sess-1' }, trust_level: 'human_present', - identity: { email: 'b@example.com', email_verified: true }, - }) - .setProtectedHeader({ alg: 'EdDSA', typ: 'jwt', kid: KID }) - .setIssuer(ISS).setIssuedAt(1715400000).setExpirationTime(1715400300) - .sign(idpPrivate); - const ctx = await signedCtx(token); // signed with the Ed25519 agent key - const r = await verifyAit(ctx, { jwks: jwksFor(idpPublicJwk), now: NOW }); - // Must be a typed failure, not a throw. - expect(r.ok).toBe(false); - if (!r.ok) { expect(r.reason).toBe('pop_signature_invalid'); } - }); -}); - -describe('verifyAit: multiple AITs', () => { - it('verifies when one of several Agent-Identity headers is valid and matches the request signature', async () => { - const good = await mintAit(); - const badIssuer = await mintAit({ iss: 'https://evil.com' }); - // request signed by the agent key (which `good` binds via cnf); 60s window for the PoP time bound - const { signatureInput, signature } = await signMessage({ ...REQ, agentIdentity: good, privateJwk: agentPrivateJwk, publicJwk: agentPublicJwk, created: 1715400010, expires: 1715400070 }); - // present the bad-issuer one first, then the good one - const ctx: VerifyRequestContext = { ...REQ, agentIdentityHeaders: [badIssuer, good], signatureInput, signature }; - const r = await verifyAit(ctx, { jwks: jwksFor(idpPublicJwk), now: NOW }); - // NB: the PoP signature covers `agentIdentity` = the specific header value, so only the - // header whose value was signed will pass. `good` was signed, so it wins. - expect(r.ok).toBe(true); - if (r.ok) { expect(r.ait.iss).toBe(ISS); } - }); -}); - -describe('verifyAit: defense sanity (importJWK round-trip)', () => { - it('the cnf key returned can be imported (well-formed JWK)', async () => { - const token = await mintAit(); - const ctx = await signedCtx(token); - const r = await verifyAit(ctx, { jwks: jwksFor(idpPublicJwk), now: NOW }); - expect(r.ok).toBe(true); - if (r.ok) { - const key = await importJWK(r.ait.cnfJwk, 'EdDSA'); - expect(key).toBeDefined(); - // thumbprint is stable - const tp = await calculateJwkThumbprint(r.ait.cnfJwk, 'sha256'); - expect(typeof tp).toBe('string'); - } - }); -}); diff --git a/tests/checkout_aip.test.ts b/tests/checkout_aip.test.ts deleted file mode 100644 index f01f6aa..0000000 --- a/tests/checkout_aip.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -/** - * Checkout gate × AIP integration. - * - * Verifies the wiring added to `Checkout.runGate`: when `gate.aip` is configured and a settle-leg - * request carries an `Agent-Identity` header, the gate verifies the AIT at the edge BEFORE the - * assess call. A present-but-invalid AIT is a hard deny (RFC 9457 problem+json); a request with no - * `Agent-Identity` header flows through the existing wallet / operator-token path unchanged. - * - * The full happy-path verify (issuer JWKS + RFC 9421 PoP) is covered at the `verifyAitParts` - * level in aip_verify / aip_gate / aip_adapters; here we assert the orchestrator contract for the - * invalid cases, which fail before any JWKS fetch (so no real crypto / network is needed). - * - * The gate runs only on the settle leg (a payment credential attached), so each request carries - * an `x-payment` header: otherwise `handle` treats it as anonymous discovery and emits a 402. - */ -import { SignJWT, exportJWK, generateKeyPair, type JWK } from 'jose'; -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import { Checkout, type CheckoutRequest } from '../src/checkout'; -import type { X402BaseRailSpec } from '../src/payment/rail_spec'; - -const AIP = { trustedIssuers: ['https://issuer.example', 'https://www.agentscore.com'] }; - -function makeCheckout(opts: { aip?: typeof AIP } = {}) { - return new Checkout({ - rails: { x402_base: { recipient: '0xTREASURY', network: 'eip155:8453' } as X402BaseRailSpec }, - url: 'https://wine.example/purchase', - computePricing: () => ({ amountUsd: 50 }), - gate: { - apiKey: 'as_test_key', - requireKyc: true, - ...(opts.aip !== undefined && { aip: opts.aip }), - }, - }); -} - -// Settle-leg marker: `x-payment` makes `handle` run the gate (and thus the AIP pre-step) -// instead of short-circuiting to an anonymous-discovery 402. -function req(headers: Record): CheckoutRequest { - return { - method: 'POST', - url: 'https://wine.example/purchase', - headers: { 'x-payment': 'eyJzdHViIjogdHJ1ZX0=', ...headers }, - body: { product_id: 'p1', quantity: 1 }, - }; -} - -describe('Checkout gate × AIP', () => { - it('hard-denies a present-but-unsigned AIT with problem+json (no fall-through)', async () => { - // Agent-Identity present but NO Signature-Input/Signature → PoP missing → hard deny. - const res = await makeCheckout({ aip: AIP }).handle( - req({ 'agent-identity': 'eyJhbGciOiJFZERTQSJ9.e30.sig' }), - ); - expect(res.status).toBe(401); - expect(res.headers['content-type']).toBe('application/problem+json'); - expect((res.body as { type?: string }).type).toBe('urn:aip:error:agent_identity_required'); - }); - - it('hard-denies a malformed AIT (garbage token + bogus signature headers)', async () => { - const res = await makeCheckout({ aip: AIP }).handle( - req({ - 'agent-identity': 'not-a-jwt', - 'signature-input': - 'ait=("@method" "@authority" "@path" "agent-identity");keyid="x";tag="agent-identity"', - signature: 'ait=:AAAA:', - }), - ); - // Malformed token → 401 invalid_signature class (never reaches the SDK assess call). - expect(res.status).toBe(401); - expect(res.headers['content-type']).toBe('application/problem+json'); - expect((res.body as { type?: string }).type).toMatch(/^urn:aip:error:/); - }); - - it('hard-denies an invalid AIT even when the gate has no apiKey (pre-step runs before the no-apiKey fallback)', async () => { - // Regression for F2: a gate.aip merchant without apiKey must still verify + hard-deny a - // present-but-invalid AIT, not silently skip AIP via the wallet-OFAC-only fallback. - const checkout = new Checkout({ - rails: { x402_base: { recipient: '0xTREASURY', network: 'eip155:8453' } as X402BaseRailSpec }, - url: 'https://wine.example/purchase', - computePricing: () => ({ amountUsd: 50 }), - gate: { requireKyc: true, aip: AIP }, // no apiKey - }); - const res = await checkout.handle(req({ 'agent-identity': 'eyJhbGciOiJFZERTQSJ9.e30.sig' })); - expect(res.status).toBe(401); - expect(res.headers['content-type']).toBe('application/problem+json'); - expect((res.body as { type?: string }).type).toBe('urn:aip:error:agent_identity_required'); - }); - - it('does not engage AIP when gate.aip is unset, even if an Agent-Identity header is sent', async () => { - // No aip config → the Agent-Identity header is ignored. The gate proceeds to the - // normal assess path; with a fake API key the SDK call fails and surfaces as a - // non-problem+json denial: the key assertion is that it is NOT the AIP problem body. - const res = await makeCheckout().handle(req({ 'agent-identity': 'eyJhbGciOiJFZERTQSJ9.e30.sig' })); - expect(res.headers['content-type']).not.toBe('application/problem+json'); - }); - - it('renders the edge-deny as problem+json THROUGH a framework adapter (not only raw handle)', async () => { - // Regression: the framework renderers used to strip the content-type + force application/json, - // silently downgrading the AIP edge-deny problem+json. The Web/Next adapter must now surface it. - const checkout = makeCheckout({ aip: AIP }); - const request = new Request('https://wine.example/purchase', { - method: 'POST', - headers: { 'x-payment': 'eyJzdHViIjogdHJ1ZX0=', 'agent-identity': 'eyJhbGciOiJFZERTQSJ9.e30.sig' }, - body: JSON.stringify({ product_id: 'p1', quantity: 1 }), - }); - const res = await checkout.handleNextjs(request); - expect(res.status).toBe(401); - expect(res.headers.get('content-type')).toBe('application/problem+json'); - const body = (await res.json()) as { type?: string }; - expect(body.type).toBe('urn:aip:error:agent_identity_required'); - }); - - it('keeps application/json for a NON-AIP denial through a framework adapter', async () => { - // The content-type override is AIP-only: a missing-identity (no Agent-Identity header) denial - // through the same renderer must stay on the application/json default, untouched. - const checkout = makeCheckout({ aip: AIP }); - const request = new Request('https://wine.example/purchase', { - method: 'POST', - // No identity header at all → missing_identity (the gate's no-AIP path), application/json. - headers: { 'x-payment': 'eyJzdHViIjogdHJ1ZX0=' }, - body: JSON.stringify({ product_id: 'p1', quantity: 1 }), - }); - const res = await checkout.handleNextjs(request); - expect(res.headers.get('content-type')).toBe('application/json'); - const body = (await res.json()) as { type?: string }; - expect(body.type).toBeUndefined(); - }); -}); - -// A *valid* signed AIT (real issuer sig + RFC 9421 PoP) requires minting + a live JWKS, so this -// block stubs global fetch to serve the issuer's JWKS. It exercises the no-apiKey offline path: -// a verified AIT through a gate that declares compliance policy but has no apiKey cannot be -// policy-evaluated (no /v1/assess), so it must FAIL CLOSED rather than allow a non-compliant id. -describe('Checkout gate × AIP: offline (no apiKey) policy enforcement', () => { - const ISS = 'https://issuer.example'; - const KID = 'partner-key'; - let idpPrivate: CryptoKey; - let idpPublicJwk: JWK; - let agentPrivateJwk: JWK; - let agentPublicJwk: JWK; - - beforeAll(async () => { - const idp = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - idpPrivate = idp.privateKey; - idpPublicJwk = { ...(await exportJWK(idp.publicKey)), kid: KID, use: 'sig', alg: 'EdDSA' }; - const agent = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - agentPrivateJwk = await exportJWK(agent.privateKey); - agentPublicJwk = await exportJWK(agent.publicKey); - }); - - afterEach(() => vi.unstubAllGlobals()); - - // Mint a real AIT + sign the request, real timestamps (Checkout's verify uses the live clock). - async function signedReq( - identity: Record, - opts: { trustLevel?: string; auth?: Record } = {}, - ): Promise { - const nowSec = Math.floor(Date.now() / 1000); - const token = await new SignJWT({ - aip_version: '0.1', sub: 'user_x', cnf: { jwk: agentPublicJwk }, - agent: { provider: 'anthropic' }, trust_level: opts.trustLevel ?? 'human_present', - ...(opts.auth ? { auth: opts.auth } : {}), identity, - }) - .setProtectedHeader({ alg: 'EdDSA', typ: 'jwt', kid: KID }) - .setIssuer(ISS).setIssuedAt(nowSec).setExpirationTime(nowSec + 300).sign(idpPrivate); - const authority = 'wine.example'; - // Lazy import sidesteps an ESM load-order quirk when both http-signature and Checkout - // (which transitively imports it) are pulled into one test module. - const { signMessage } = await import('../src/aip/http-signature'); - const { signatureInput, signature } = await signMessage({ - method: 'POST', authority, path: '/purchase', agentIdentity: token, - // PoP verifier requires `expires` (replay-window hardening); 60s window like pay. - privateJwk: agentPrivateJwk, publicJwk: agentPublicJwk, created: nowSec, expires: nowSec + 60, - }); - // Stub the JWKS fetch the Checkout-internal JwksCache will make for ISS. - vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ keys: [idpPublicJwk] }), { - status: 200, headers: { 'content-type': 'application/jwk-set+json', 'cache-control': 'max-age=300' }, - }))); - return { - method: 'POST', url: 'https://wine.example/purchase', - headers: { host: authority, 'x-payment': 'eyJzdHViIjogdHJ1ZX0=', 'agent-identity': token, 'signature-input': signatureInput, signature }, - body: { product_id: 'p1', quantity: 1 }, - }; - } - - const offlineGate = (extra: Record) => new Checkout({ - rails: { x402_base: { recipient: '0xT', network: 'eip155:8453' } as X402BaseRailSpec }, - url: 'https://wine.example/purchase', - computePricing: () => ({ amountUsd: 50 }), - gate: { aip: { trustedIssuers: [ISS] }, ...extra }, // NO apiKey - }); - - it('FAILS CLOSED when a policy-bearing gate has no apiKey, even for a valid AIT', async () => { - const res = await offlineGate({ minAge: 21 }).handle(await signedReq({ age_over_18: true })); - expect(res.status).toBe(403); - expect((res.body as { error?: { code?: string } }).error?.code).toBe('aip_policy_requires_api_key'); - }); - - it('allows a valid AIT on an identity-only gate (reaches settle, not blocked by gate)', async () => { - // Identity-only gate (no policy fields) + verified AIT + no apiKey -> the gate returns null - // (allow) and Checkout proceeds to x402 settle. The fetch stub only serves JWKS, so the - // downstream facilitator init throws -- but reaching settle at all proves the gate did NOT - // block with aip_policy_requires_api_key. (The fail-closed counterpart returns from the gate - // before settle, so it asserts on the body directly.) - await expect(offlineGate({}).handle(await signedReq({ id_verified: true }))) - .rejects.toThrow(/facilitator|payment kinds/i); - }); - - const trustGate = (aipExtra: Record) => new Checkout({ - rails: { x402_base: { recipient: '0xT', network: 'eip155:8453' } as X402BaseRailSpec }, - url: 'https://wine.example/purchase', - computePricing: () => ({ amountUsd: 50 }), - gate: { aip: { trustedIssuers: [ISS], ...aipExtra } }, // NO apiKey: trust check runs pre-policy - }); - - it('denies weak_auth (403 + required_trust_level) when trust_level is below the gate requirement', async () => { - const res = await trustGate({ requireTrustLevel: 'human_confirmed' }) - .handle(await signedReq({ id_verified: true }, { trustLevel: 'human_present' })); - expect(res.status).toBe(403); - expect(res.headers['content-type']).toBe('application/problem+json'); - expect((res.body as { type?: string }).type).toBe('urn:aip:error:weak_auth'); - expect((res.body as { required_trust_level?: string }).required_trust_level).toBe('human_confirmed'); - }); - - it('denies weak_auth (403 + required_amr) when no auth.amr matches the gate requirement', async () => { - const res = await trustGate({ requireAmr: ['face', 'fpt', 'hwk'] }) - .handle(await signedReq({ id_verified: true }, { trustLevel: 'human_confirmed', auth: { amr: ['pwd'] } })); - expect(res.status).toBe(403); - expect((res.body as { type?: string }).type).toBe('urn:aip:error:weak_auth'); - expect((res.body as { required_amr?: string[] }).required_amr).toEqual(['face', 'fpt', 'hwk']); - }); - - it('passes the trust gate when trust_level + amr satisfy it (reaches settle)', async () => { - await expect( - trustGate({ requireTrustLevel: 'human_confirmed', requireAmr: ['face'] }) - .handle(await signedReq({ id_verified: true }, { trustLevel: 'human_confirmed', auth: { amr: ['face'] } })), - ).rejects.toThrow(/facilitator|payment kinds/i); - }); -}); - -// Issuer-conditional policy: a gate keeps its full default compliance policy for its own AITs, -// but applies a relaxed per-issuer override for a named partner issuer (e.g. an external IdP). Uses the -// no-apiKey path as a deterministic probe: a policy-bearing request that can't be evaluated -// FAILS CLOSED (`aip_policy_requires_api_key`); a request whose effective policy is EMPTY passes -// the gate (returns null → reaches x402 settle → facilitator-init throws on the JWKS-only stub). -// So "fails closed" = policy was applied to this issuer; "throws at settle" = policy was empty. -describe('Checkout gate × AIP: issuer-conditional policy', () => { - const PARTNER = 'https://issuer.example'; - const OURS = 'https://www.agentscore.com'; - const keys: Record = {}; - let agentPriv: JWK; - let agentPub: JWK; - - beforeAll(async () => { - for (const [iss, kid] of [[PARTNER, 'partner-key'], [OURS, 'as-key']] as const) { - const kp = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - keys[iss] = { priv: kp.privateKey, pubJwk: { ...(await exportJWK(kp.publicKey)), kid, use: 'sig', alg: 'EdDSA' }, kid }; - } - const a = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - agentPriv = await exportJWK(a.privateKey); - agentPub = await exportJWK(a.publicKey); - }); - - afterEach(() => vi.unstubAllGlobals()); - - async function signedReqFrom(iss: string, identity: Record): Promise { - const k = keys[iss]!; - const nowSec = Math.floor(Date.now() / 1000); - const token = await new SignJWT({ - aip_version: '0.1', sub: 'user_x', cnf: { jwk: agentPub }, - agent: { provider: 'anthropic' }, trust_level: 'human_present', identity, - }) - .setProtectedHeader({ alg: 'EdDSA', typ: 'jwt', kid: k.kid }) - .setIssuer(iss).setIssuedAt(nowSec).setExpirationTime(nowSec + 300).sign(k.priv); - const authority = 'wine.example'; - const { signMessage } = await import('../src/aip/http-signature'); - const { signatureInput, signature } = await signMessage({ - method: 'POST', authority, path: '/purchase', agentIdentity: token, - // PoP verifier requires `expires` (replay-window hardening); 60s window like pay. - privateJwk: agentPriv, publicJwk: agentPub, created: nowSec, expires: nowSec + 60, - }); - // Serve whichever issuer's JWKS is being fetched (keyed by request URL host). - vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { - const u = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; - const which = u.includes('issuer.example') ? PARTNER : OURS; - return new Response(JSON.stringify({ keys: [keys[which]!.pubJwk] }), { - status: 200, headers: { 'content-type': 'application/jwk-set+json', 'cache-control': 'max-age=300' }, - }); - })); - return { - method: 'POST', url: 'https://wine.example/purchase', - headers: { host: authority, 'x-payment': 'eyJzdHViIjogdHJ1ZX0=', 'agent-identity': token, 'signature-input': signatureInput, signature }, - body: { product_id: 'p1', quantity: 1 }, - }; - } - - // Wine-style gate: full default policy, with the partner relaxed to KYC + 21 only (no sanctions/jurisdiction). - const gate = () => new Checkout({ - rails: { x402_base: { recipient: '0xT', network: 'eip155:8453' } as X402BaseRailSpec }, - url: 'https://wine.example/purchase', - computePricing: () => ({ amountUsd: 50 }), - gate: { - requireKyc: true, requireSanctionsClear: true, minAge: 21, allowedJurisdictions: ['US'], - aip: { trustedIssuers: [PARTNER, OURS], issuerPolicies: { [PARTNER]: { requireKyc: true, minAge: 21 } } }, - }, // NO apiKey → policy-presence probe - }); - - it('applies the full default policy to the merchant’s own (non-overridden) issuer → fails closed', async () => { - const res = await gate().handle(await signedReqFrom(OURS, { id_verified: true, age_over_21: true })); - expect(res.status).toBe(403); - expect((res.body as { error?: { code?: string } }).error?.code).toBe('aip_policy_requires_api_key'); - }); - - it('applies the relaxed override to the named partner issuer: still policy-bearing → fails closed (not silently allowed)', async () => { - // Relaxed ≠ empty: The partner still requires KYC + 21, so a no-apiKey gate still can't evaluate it - // and fails closed. This proves the override is policy-BEARING, not a bypass. - const res = await gate().handle(await signedReqFrom(PARTNER, { id_verified: true, age_over_21: true })); - expect(res.status).toBe(403); - expect((res.body as { error?: { code?: string } }).error?.code).toBe('aip_policy_requires_api_key'); - }); - - it('an EMPTY issuer override drops all policy for that issuer → gate allows (reaches settle)', async () => { - // A merchant can relax an issuer all the way to identity-only with `{}`. Then the effective - // policy is empty → the no-apiKey gate returns null (allow) → settle → facilitator throws. - const emptyOverrideGate = new Checkout({ - rails: { x402_base: { recipient: '0xT', network: 'eip155:8453' } as X402BaseRailSpec }, - url: 'https://wine.example/purchase', - computePricing: () => ({ amountUsd: 50 }), - gate: { - requireKyc: true, requireSanctionsClear: true, minAge: 21, - aip: { trustedIssuers: [PARTNER, OURS], issuerPolicies: { [PARTNER]: {} } }, - }, - }); - await expect(emptyOverrideGate.handle(await signedReqFrom(PARTNER, { email_verified: true }))) - .rejects.toThrow(/facilitator|payment kinds/i); - }); - - it('matches issuer overrides after canonicalization (trailing-slash key still applies)', async () => { - const trailingSlashGate = new Checkout({ - rails: { x402_base: { recipient: '0xT', network: 'eip155:8453' } as X402BaseRailSpec }, - url: 'https://wine.example/purchase', - computePricing: () => ({ amountUsd: 50 }), - gate: { - requireKyc: true, requireSanctionsClear: true, minAge: 21, - // key has a trailing slash; verified iss is 'https://issuer.example': must still match. - aip: { trustedIssuers: [PARTNER, OURS], issuerPolicies: { 'https://issuer.example/': {} } }, - }, - }); - await expect(trailingSlashGate.handle(await signedReqFrom(PARTNER, { email_verified: true }))) - .rejects.toThrow(/facilitator|payment kinds/i); - }); -}); diff --git a/tests/checkout_aip_forward.test.ts b/tests/checkout_aip_forward.test.ts deleted file mode 100644 index 4201c20..0000000 --- a/tests/checkout_aip_forward.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Checkout × AIP: assess forwarding contract. - * - * After the edge fail-fast verify passes, the gate must forward BOTH the `aip_token` AND the - * RFC 9421 signature material to `/v1/assess`, so the API (the authoritative verifier) re-checks - * proof-of-possession itself instead of trusting the edge. This pins that the material reaches - * `sdk.assess`. The SDK is mocked to capture the call args (and short-circuit the network). - */ -import { SignJWT, exportJWK, generateKeyPair, type JWK } from 'jose'; -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import { Checkout, type CheckoutRequest } from '../src/checkout'; -import type { X402BaseRailSpec } from '../src/payment/rail_spec'; - -const { assessCalls, assessState } = vi.hoisted(() => ({ - assessCalls: [] as Array<{ address: string | null; options: Record }>, - // Mutable per-test response from the mocked SDK assess. Defaults to allow; a test can flip it to - // a deny decision to exercise the AIT policy-deny superset body. - assessState: { response: { decision: 'allow', decision_reasons: ['no_policy_applied'] } as Record }, -})); - -vi.mock('@agent-score/sdk', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - AgentScore: class { - async assess(address: string | null, options: Record) { - assessCalls.push({ address, options }); - return { - explanation: [], - identity_method: 'aip_token', - operator_verification: { level: 'kyc_verified', operator_type: null, verified_at: null }, - ...assessState.response, - }; - } - }, - }; -}); - -const ISS = 'https://issuer.example'; -const KID = 'partner-key'; -let idpPrivate: CryptoKey; -let idpPublicJwk: JWK; -let agentPrivateJwk: JWK; -let agentPublicJwk: JWK; - -beforeAll(async () => { - const idp = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - idpPrivate = idp.privateKey; - idpPublicJwk = { ...(await exportJWK(idp.publicKey)), kid: KID, use: 'sig', alg: 'EdDSA' }; - const agent = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); - agentPrivateJwk = await exportJWK(agent.privateKey); - agentPublicJwk = await exportJWK(agent.publicKey); -}); - -afterEach(() => { - vi.unstubAllGlobals(); - assessCalls.length = 0; - assessState.response = { decision: 'allow', decision_reasons: ['no_policy_applied'] }; -}); - -async function signedReq(): Promise { - const nowSec = Math.floor(Date.now() / 1000); - const token = await new SignJWT({ - aip_version: '0.1', - sub: 'user_x', - cnf: { jwk: agentPublicJwk }, - agent: { provider: 'anthropic' }, - trust_level: 'human_present', - identity: { id_verified: true }, - }) - .setProtectedHeader({ alg: 'EdDSA', typ: 'jwt', kid: KID }) - .setIssuer(ISS) - .setIssuedAt(nowSec) - .setExpirationTime(nowSec + 300) - .sign(idpPrivate); - const authority = 'wine.example'; - const { signMessage } = await import('../src/aip/http-signature'); - const { signatureInput, signature } = await signMessage({ - method: 'POST', - authority, - path: '/purchase', - agentIdentity: token, - privateJwk: agentPrivateJwk, - publicJwk: agentPublicJwk, - created: nowSec, - // PoP verifier requires `expires` (replay-window hardening); 60s window like pay. - expires: nowSec + 60, - }); - // The edge verify fetches the issuer JWKS; stub it. The SDK assess is mocked (no network). - vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ keys: [idpPublicJwk] }), { - status: 200, - headers: { 'content-type': 'application/jwk-set+json', 'cache-control': 'max-age=300' }, - }))); - return { - method: 'POST', - url: 'https://wine.example/purchase', - headers: { - host: authority, - 'x-payment': 'eyJzdHViIjogdHJ1ZX0=', - 'agent-identity': token, - 'signature-input': signatureInput, - signature, - }, - body: { product_id: 'p1', quantity: 1 }, - }; -} - -describe('Checkout × AIP: assess forwarding', () => { - it('forwards aip_token + the RFC 9421 signature material to /v1/assess', async () => { - const checkout = new Checkout({ - rails: { x402_base: { recipient: '0xT', network: 'eip155:8453' } as X402BaseRailSpec }, - url: 'https://wine.example/purchase', - computePricing: () => ({ amountUsd: 50 }), - gate: { apiKey: 'as_test_key', aip: { trustedIssuers: [ISS] } }, - }); - // assess (mocked) returns allow → checkout proceeds toward x402 settle, which throws on the - // JWKS-only fetch stub. We only assert that assess was reached with the forwarded material. - await checkout.handle(await signedReq()).catch(() => {}); - - expect(assessCalls).toHaveLength(1); - const opts = assessCalls[0]!.options; - expect(typeof opts.aipToken).toBe('string'); - expect(opts.aipSignature).toMatchObject({ method: 'POST', authority: 'wine.example', path: '/purchase' }); - const sig = opts.aipSignature as { signature_input: string; signature: string }; - expect(typeof sig.signature_input).toBe('string'); - expect(typeof sig.signature).toBe('string'); - }); -}); - -describe('Checkout × AIP: policy-deny superset body', () => { - it('emits the RFC 9457 + AgentScore superset (problem+json) when /v1/assess denies a verified AIT', async () => { - // A verified AIT that /v1/assess then DENIES on compliance (e.g. sanctions). The body must be - // BOTH schemes: the AgentScore `{ error.code, agent_instructions, reasons }` AND the spec's - // `type: urn:aip:error:insufficient_claims` / status 403: content-negotiated as problem+json. - assessState.response = { decision: 'deny', decision_reasons: ['sanctions_flagged'] }; - const checkout = new Checkout({ - rails: { x402_base: { recipient: '0xT', network: 'eip155:8453' } as X402BaseRailSpec }, - url: 'https://wine.example/purchase', - computePricing: () => ({ amountUsd: 50 }), - gate: { apiKey: 'as_test_key', requireSanctionsClear: true, minAge: 21, aip: { trustedIssuers: [ISS] } }, - }); - const res = await checkout.handle(await signedReq()); - - expect(res.status).toBe(403); - expect(res.headers['content-type']).toBe('application/problem+json'); - // RFC 9457 + AIP-spec envelope. - expect((res.body as { type?: string }).type).toBe('urn:aip:error:insufficient_claims'); - expect((res.body as { title?: string }).title).toBe('insufficient claims'); - expect((res.body as { status?: number }).status).toBe(403); - expect((res.body as { detail?: string }).detail).toContain('sanctions_flagged'); - // Escalation hint derived from the gate's effective policy. - expect((res.body as { required_claims?: string[] }).required_claims).toEqual(['sanctions_clear', 'age_over_21']); - // Rich AgentScore scheme preserved verbatim: still the source of truth for the agent. - expect((res.body as { error?: { code?: string } }).error?.code).toBe('wallet_not_trusted'); - expect((res.body as { reasons?: string[] }).reasons).toEqual(['sanctions_flagged']); - expect((res.body as { agent_instructions?: string }).agent_instructions).toBeTruthy(); - }); - - it('maps an expired AIT credential to expired_token (401) while keeping the AgentScore body', async () => { - // The SDK surfaces a server-side AIT rejection; on the AIT path it becomes the spec's - // expired_token / invalid_signature family, not insufficient_claims. - assessState.response = { decision: 'deny', decision_reasons: ['kyc_required'] }; - const checkout = new Checkout({ - rails: { x402_base: { recipient: '0xT', network: 'eip155:8453' } as X402BaseRailSpec }, - url: 'https://wine.example/purchase', - computePricing: () => ({ amountUsd: 50 }), - gate: { apiKey: 'as_test_key', requireKyc: true, aip: { trustedIssuers: [ISS] } }, - }); - const res = await checkout.handle(await signedReq()); - // kyc_required is a compliance reason → insufficient_claims (403), required_claims = id_verified. - expect(res.status).toBe(403); - expect((res.body as { type?: string }).type).toBe('urn:aip:error:insufficient_claims'); - expect((res.body as { required_claims?: string[] }).required_claims).toEqual(['id_verified']); - expect((res.body as { error?: { code?: string } }).error?.code).toBe('wallet_not_trusted'); - }); -}); diff --git a/tests/core_signer_cache.test.ts b/tests/core_signer_cache.test.ts index 12ee460..07a8fee 100644 --- a/tests/core_signer_cache.test.ts +++ b/tests/core_signer_cache.test.ts @@ -1,8 +1,8 @@ /** * Cache-poisoning bypass of wallet-signer binding + signer-OFAC (src/core.ts). * - * The assess response cache was keyed by IDENTITY only (claimed wallet / operator_token / aip - * hash); the payment SIGNER was not part of the key. So a 2nd request inside `cacheSeconds` that + * The assess response cache was keyed by IDENTITY only (claimed wallet / operator_token); the + * payment SIGNER was not part of the key. So a 2nd request inside `cacheSeconds` that * claimed the same wallet but signed with a DIFFERENT wallet hit the cached decision and returned * the first signer's stale `signer_match: pass` / `signer_sanctions: clear`: the API never * re-screened the new signer. Variant: a sanctioned 2nd signer would settle on the cached `clear`. diff --git a/tests/identity/a2a.test.ts b/tests/identity/a2a.test.ts index d9158b0..524ae32 100644 --- a/tests/identity/a2a.test.ts +++ b/tests/identity/a2a.test.ts @@ -2,9 +2,7 @@ import { describe, expect, it } from 'vitest'; import { A2A_DEFAULT_TRANSPORT, A2A_PROTOCOL_VERSION, - AIP_A2A_EXTENSION_URI, UCP_A2A_EXTENSION_URI, - aipA2AExtension, buildA2AAgentCard, ucpA2AExtension, type A2AAgentCardExtension, @@ -20,34 +18,6 @@ const DEFAULT_SKILL: A2AAgentSkill = { tags: ['commerce', 'payment'], }; -describe('aipA2AExtension', () => { - it('advertises AIP with the canonical URI, header, RFC 9421, and trusted issuers', () => { - const ext = aipA2AExtension({ trustedIssuers: ['https://www.agentscore.com', 'https://issuer.example'] }); - expect(ext.uri).toBe(AIP_A2A_EXTENSION_URI); - expect(ext.required).toBe(false); - expect(ext.params).toMatchObject({ - header: 'Agent-Identity', - signature: 'RFC 9421', - trusted_issuers: ['https://www.agentscore.com', 'https://issuer.example'], - }); - }); - - it('surfaces required_trust_level / required_amr when set; omits them otherwise', () => { - expect(aipA2AExtension().params).not.toHaveProperty('required_trust_level'); - const ext = aipA2AExtension({ requiredTrustLevel: 'human_confirmed', requiredAmr: ['face', 'hwk'] }); - expect(ext.params).toMatchObject({ required_trust_level: 'human_confirmed', required_amr: ['face', 'hwk'] }); - }); - - it('appears in a built card\'s capabilities.extensions[]', () => { - const card = buildA2AAgentCard({ - name: 'M', description: 'd', url: 'https://m.example', skills: [DEFAULT_SKILL], - extensions: [aipA2AExtension({ trustedIssuers: ['https://www.agentscore.com'] })], - }); - const uris = (card.capabilities.extensions ?? []).map((e: A2AAgentCardExtension) => e.uri); - expect(uris).toContain(AIP_A2A_EXTENSION_URI); - }); -}); - describe('buildA2AAgentCard (A2A v1.0 wire format)', () => { it('emits the minimum required spec fields', () => { const card = buildA2AAgentCard({ diff --git a/tests/verification_session.test.ts b/tests/verification_session.test.ts index 40a10ad..a90a82c 100644 --- a/tests/verification_session.test.ts +++ b/tests/verification_session.test.ts @@ -23,14 +23,15 @@ describe('verification-session request helpers', () => { const base = { 'x-verification-session': 'create' }; expect(requestsVerificationSession({ ...base, 'x-operator-token': 'opc_x' })).toBe(false); expect(requestsVerificationSession({ ...base, 'x-wallet-address': '0xabc' })).toBe(false); - expect(requestsVerificationSession({ ...base, 'agent-identity': 'eyJ.e30.sig' })).toBe(false); expect(requestsVerificationSession({ ...base, authorization: 'Payment abc' })).toBe(false); expect(requestsVerificationSession({ ...base, 'x-payment': 'abc' })).toBe(false); }); - it('treats an empty Agent-Identity value as no identity', () => { - expect(hasIdentityHeader({ 'agent-identity': ' , ' })).toBe(false); - expect(hasIdentityHeader({ 'agent-identity': 'eyJ.e30.sig' })).toBe(true); + it('counts only an operator token or a wallet address as an identity', () => { + expect(hasIdentityHeader({ 'x-operator-token': 'opc_x' })).toBe(true); + expect(hasIdentityHeader({ 'x-wallet-address': '0xabc' })).toBe(true); + expect(hasIdentityHeader({ 'x-verification-session': 'create' })).toBe(false); + expect(hasIdentityHeader({})).toBe(false); }); it('runs the conditional gate on a payment credential or a session request, and not otherwise', () => {