From 264c56aeae70e1fb673b86fd3faa0dd7f1308b2c Mon Sep 17 00:00:00 2001 From: Jeremie Charrier Date: Tue, 23 Jun 2026 12:31:48 +0200 Subject: [PATCH 1/7] [WALLET-87] Update x401-node to spec 0.2.0 Rework the SDK for x401 0.2.0: flat payload carrying the Verifier-composed Digital Credentials request at presentation_requirements, VP Artifact with inline result or presentation_uri reference, and removal of the Verifier Challenge (delete challenge.ts; keep createEncryptor as a standalone primitive). Add a spec-conformance harness pinned to the upstream spec: vendored Appendix C JSON Schema + extracted examples, sync/normative-ledger scripts, schema and fixture round-trip tests, a requirement->code map, and an upgrade runbook. Co-Authored-By: Claude Opus 4.8 (1M context) --- .prettierignore | 8 + CLAUDE.md | 36 +- README.md | 161 +-- package.json | 2 + scripts/extract-normative.ts | 99 ++ scripts/sync-spec-fixtures.ts | 155 +++ spec/SPEC_SOURCE.json | 9 + spec/UPGRADING.md | 84 ++ spec/conformance.md | 61 + spec/fixtures/error-object.json | 7 + spec/fixtures/openid4vp-request-1.json | 36 + spec/fixtures/openid4vp-request-2.json | 35 + spec/fixtures/payload-1.json | 10 + spec/fixtures/payload-2.json | 21 + spec/fixtures/payload-3.json | 22 + spec/fixtures/payload-4.json | 17 + spec/fixtures/request.schema.json | 129 ++ spec/fixtures/token-object.json | 6 + spec/fixtures/vp-artifact-1.json | 7 + spec/fixtures/vp-artifact-2.json | 7 + spec/fixtures/vp-artifact-3.json | 5 + spec/fixtures/vp-artifact-4.json | 7 + spec/fixtures/vp-artifact-5.json | 5 + spec/normative-ledger.json | 85 ++ spec/spec.md | 1602 ++++++++++++++++++++++++ src/agent.ts | 80 +- src/challenge.ts | 159 --- src/constants.ts | 13 +- src/index.ts | 8 +- src/types.ts | 89 +- src/validate.ts | 95 +- src/verifier.ts | 90 +- tests/spec-fixtures.test.ts | 92 ++ tests/spec-schema.test.ts | 125 ++ tests/x401.test.ts | 333 +++-- yarn.lock | 32 + 36 files changed, 3125 insertions(+), 607 deletions(-) create mode 100644 .prettierignore create mode 100644 scripts/extract-normative.ts create mode 100644 scripts/sync-spec-fixtures.ts create mode 100644 spec/SPEC_SOURCE.json create mode 100644 spec/UPGRADING.md create mode 100644 spec/conformance.md create mode 100644 spec/fixtures/error-object.json create mode 100644 spec/fixtures/openid4vp-request-1.json create mode 100644 spec/fixtures/openid4vp-request-2.json create mode 100644 spec/fixtures/payload-1.json create mode 100644 spec/fixtures/payload-2.json create mode 100644 spec/fixtures/payload-3.json create mode 100644 spec/fixtures/payload-4.json create mode 100644 spec/fixtures/request.schema.json create mode 100644 spec/fixtures/token-object.json create mode 100644 spec/fixtures/vp-artifact-1.json create mode 100644 spec/fixtures/vp-artifact-2.json create mode 100644 spec/fixtures/vp-artifact-3.json create mode 100644 spec/fixtures/vp-artifact-4.json create mode 100644 spec/fixtures/vp-artifact-5.json create mode 100644 spec/normative-ledger.json create mode 100644 spec/spec.md delete mode 100644 src/challenge.ts create mode 100644 tests/spec-fixtures.test.ts create mode 100644 tests/spec-schema.test.ts diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..a790604 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +dist + +# Verbatim spec cache + extracted fixtures — must match upstream byte-for-byte, +# so the conformance harness checks against spec text, not a reformatted copy. +# Regenerated by scripts/sync-spec-fixtures.ts. +spec/spec.md +spec/fixtures/ +spec/normative-ledger.json diff --git a/CLAUDE.md b/CLAUDE.md index 235f973..bbe63ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,18 +1,25 @@ # @proof.com/x401-node - AI Assistant Guide -ESM TypeScript library implementing the x401 protocol (https://x401.proof.com/spec): -the PROOF-REQUIRED / PROOF-PRESENTATION / PROOF-RESPONSE wire format, the Verifier Challenge, -the VP Artifact, the x401 Token / Error objects, and the OAuth Token Exchange profile. +ESM TypeScript library implementing the x401 protocol (https://x401.proof.com/spec, **v0.2.0**): +the PROOF-REQUIRED / PROOF-PRESENTATION / PROOF-RESPONSE wire format, the composed Digital +Credentials request (`presentation_requirements`), the VP Artifact (inline result or +`presentation_uri` reference), the x401 Token / Error objects, and the OAuth Token Exchange profile. Two consumer roles, exported as namespaces: -- `agent.*` — decode PROOF-REQUIRED (header or embedded ``), package a wallet result as a VP - Artifact, encode PROOF-PRESENTATION, build a token-exchange request, decode PROOF-RESPONSE errors. -- `verifier.*` — create/verify the Verifier Challenge, build/encode the payload, emit the embedded - `` mirror, decode incoming VP Artifacts / Token Objects, parse token-exchange requests, - encode error objects. +- `agent.*` — decode PROOF-REQUIRED (header or embedded ``), read the Verifier-composed + `presentation_requirements`, package a presentation result as a VP Artifact (inline or by + reference), encode PROOF-PRESENTATION, build a token-exchange request, decode PROOF-RESPONSE errors. +- `verifier.*` — build/encode the flat payload (carrying the caller-composed + `presentation_requirements`), emit the embedded `` mirror, decode incoming VP Artifacts / + Token Objects, parse token-exchange requests, encode error objects. -Plus `createEncryptor` (AES-GCM verifier-protected nonce state). +Plus `createEncryptor` (AES-GCM + HKDF authenticated-state primitive; e.g. for a verifier to seal +its own stateless OpenID4VP `nonce`). + +Spec-conformance harness lives under `spec/` (pinned schema + extracted examples + normative ledger) +and `scripts/` (`sync-spec-fixtures.ts`, `extract-normative.ts`). See `spec/UPGRADING.md` for the +repeatable spec-upgrade loop and `spec/conformance.md` for the requirement→code map. ## Hard Rules @@ -55,14 +62,13 @@ Plus `createEncryptor` (AES-GCM verifier-protected nonce state). ## Source Map -- `src/constants.ts` — scheme/version, header names, schema URL, token-exchange URNs. -- `src/types.ts` — wire-format types (no runtime code). +- `src/constants.ts` — scheme/version (`0.2.0`), `DC_API_PROTOCOL` (signed/unsigned), header names, schema URL, token-exchange URNs. +- `src/types.ts` — wire-format types (no runtime code): flat `X401Payload`, `DigitalCredentialRequest`, `PresentationResult`, `VPArtifact`. - `src/encoding.ts` — base64url JSON helpers over `@owf/identity-common`; proof-header comma guard. - `src/validate.ts` — structural validators / type guards (`X401ValidationError`). -- `src/encryptor.ts` — `createEncryptor` (AES-GCM + HKDF verifier-protected nonce state; `encrypt`/`decrypt`). -- `src/challenge.ts` — Verifier Challenge construct/verify (binds verifier id, route, method, expiry). -- `src/agent.ts` — agent-side primitives. -- `src/verifier.ts` — verifier-side primitives (re-exports challenge functions). +- `src/encryptor.ts` — `createEncryptor` (AES-GCM + HKDF authenticated-state primitive; `encrypt`/`decrypt`). +- `src/agent.ts` — agent-side primitives (`getDigitalCredentialRequest`, `buildVPArtifact`/`buildVPArtifactReference`, …). +- `src/verifier.ts` — verifier-side primitives (`buildPayload`, `embedHtmlData`, decoders, token-exchange parse, error builder). - `src/index.ts` — public barrel (explicit named exports; `agent`/`verifier` namespaces). ## Publishing diff --git a/README.md b/README.md index d2846e9..533dd58 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,24 @@ # @proof.com/x401-node -Node.js SDK for the [x401 protocol](https://x401.proof.com/spec). +Node.js SDK for the [x401 protocol](https://x401.proof.com/spec) (v0.2.0). x401 gates an HTTP resource behind an identity proof requirement. The server (_verifier_) returns a -[`PROOF-REQUIRED`](https://x401.proof.com/spec/#proof-header-fields) header and the user _agent_ retries -with a [`PROOF-PRESENTATION`](https://x401.proof.com/spec/#route-retry-headers) header carrying a -Verifiable Credential Presentation. This package implements the data types and processing rules for both the _verifier_ and the user _agent_. +[`PROOF-REQUIRED`](https://x401.proof.com/spec/#proof-header-fields) header carrying a composed +[Digital Credentials API](https://www.w3.org/TR/digital-credentials/) request; the user _agent_ +obtains a presentation for that request and retries with a +[`PROOF-PRESENTATION`](https://x401.proof.com/spec/#route-retry-headers) header. This package +implements the data types and processing rules for both the _verifier_ and the user _agent_. -It does **not** verify credentials — the `vp_token` is opaque, so pair it with a credential library -such as [`@proof.com/proof-vc-common`](https://www.npmjs.com/package/@proof.com/proof-vc-common). It -also does **not** build the wallet-facing OpenID4VP request; that is the user agent's responsibility. +It does **not** verify credentials — the presentation result is opaque, so pair it with a credential +library such as [`@proof.com/proof-vc-common`](https://www.npmjs.com/package/@proof.com/proof-vc-common). +It also does **not** compose or sign the OpenID4VP request, nor invoke the wallet; the verifier +authors the request (out of scope here) and this package carries it opaque in `presentation_requirements`. ## Table of Contents - [Installation](#installation) - [Verifier](#verifier) - [Protect a resource (`PROOF-REQUIRED`)](#protect-a-resource-proof-required) - - [Proof challenge](#proof-challenge) - - [Proof requirement](#proof-requirement) - [Verify a Proof (`PROOF-PRESENTATION`)](#verify-a-proof-proof-presentation) - [Agent](#agent) - [Read a Proof requirement (`PROOF-REQUIRED`)](#read-a-proof-requirement-proof-required) @@ -35,94 +36,58 @@ npm install @proof.com/x401-node ### Protect a resource (`PROOF-REQUIRED`) -A protected route returns a [Proof requirement](#proof-requirement) built around a -[Proof challenge](#proof-challenge). - -#### Proof challenge - -The Proof challenge contains a nonce tied to the resource the agent wants to access. The agent -submits that nonce, inside a [VP Artifact](https://x401.proof.com/spec/#vp-artifact), to access the -protected resource. The challenge must follow the -[challenge format](https://x401.proof.com/spec/#verifier-challenge-format). Provide your own, or use -the built-in challenge encryptor to create one. - -##### Built-in challenge encryptor - -`createEncryptor` binds the route context into the nonce, so the verifier holds no per-challenge -state. The same secret must be present wherever challenges are verified. - -```ts -import { createEncryptor, verifier } from "@proof.com/x401-node"; - -const encryptor = createEncryptor({ key: process.env.X401_KEY! }); - -const challenge = await verifier.createChallenge({ - verifierId: "https://research.example.com", - resource: "https://research.example.com/papers/medical-study-123", - method: "GET", - encryptor, - ttlSeconds: 600, -}); -``` - -The nonce is an AES-256-GCM token (HKDF-derived key). [Verify a Proof](#verify-a-proof-proof-presentation) -rejects any value whose nonce was tampered with. - -##### Supply your own challenge - -You can construct a [`VerifierChallenge`](https://x401.proof.com/spec/#verifier-challenge-format) if you prefer storing -the challenge server side or prefer a different nonce generation algorithm. +The [x401 payload](https://x401.proof.com/spec/#x401-payload) carries the Verifier-composed +[Digital Credentials request](https://x401.proof.com/spec/#presentation-requirements) and the OAuth +token endpoint used for [token exchange](#exchange-a-proof-for-a-token). You compose and (for the +RECOMMENDED signed mode) sign the OpenID4VP request yourself; this package carries it opaque. ```ts -const challenge = { - value: `x401:${Buffer.from("https://research.example.com").toString("base64url")}:${myStoredNonce}`, - expires_at: new Date(Date.now() + 600_000).toISOString(), -}; -``` - -#### Proof requirement - -The [x401 payload](https://x401.proof.com/spec/#x401-payload) carries the challenge, the credential -query and the OAuth token endpoint used for [token exchange](#exchange-a-proof-for-a-token). - -##### Create the payload +import { verifier } from "@proof.com/x401-node"; -`buildPayload` requires exactly one credential query: `dcql_query` or `scope`. `oauth.token_endpoint` -is required. - -```ts const payload = verifier.buildPayload({ - proof: { - challenge, - oauth: { token_endpoint: "https://research.example.com/oauth/token" }, - scope: "urn:proof:params:scope:verifiable-credentials:basic", + presentationRequirements: { + requests: [ + { + protocol: "openid4vp-v1-signed", // or "openid4vp-v1-unsigned" + data: { request: signedOpenId4vpRequestJwt }, // composed + signed by you + }, + ], }, + oauth: { token_endpoint: "https://research.example.com/oauth/token" }, + // optional hints: + trustEstablishment: + "https://research.example.com/.well-known/x401/trust/basic-v1", + requestId: "proof-template-basic-v1", + satisfiedRequirements: ["urn:proof:x401:satisfaction:basic:v1"], }); ``` -##### Payload in the header - -Return the Proof requirement as a header: +Return it as a header: ```ts response.setHeader("PROOF-REQUIRED", verifier.encodePayload(payload)); ``` -##### Payload in HTML - For clients that read the body but not the headers, mirror the requirement as an -[embedded `` element](https://x401.proof.com/spec/#embedded-proof-requirements-in-html-content). -The header remains authoritative and must still be set. +[embedded `` element](https://x401.proof.com/spec/#embedded-proof-requirements-in-html-content) +(the `$schema` marker is added automatically). The header remains authoritative and must still be set. ```ts const html = `
${verifier.embedHtmlData(payload)}`; ``` +> **Stateless nonce (optional).** x401 0.2.0 has no Verifier Challenge; freshness/replay live in the +> OpenID4VP `nonce` inside your request. To operate statelessly you can seal route context into that +> `nonce` with `createEncryptor` (an AES-256-GCM + HKDF authenticated-state primitive) and recover it +> on retry. The same secret must be present wherever you validate. + ### Verify a Proof (`PROOF-PRESENTATION`) -Decode the artifact and authenticate the challenge. Then verify `vp_token` with your credential -library and apply route policy. On failure, return an -[x401 Error Object](https://x401.proof.com/spec/#x401-error-object) in `PROOF-RESPONSE`. See the full +Decode the artifact, then validate the presentation against the request you composed (binding, +`nonce` freshness, credential query) with your credential library and route policy. The artifact may +carry the result inline (`response`) or by reference (`presentation_uri`, which you dereference). On +failure, return an [x401 Error Object](https://x401.proof.com/spec/#x401-error-object) in +`PROOF-RESPONSE`. See the full [verifier processing rules](https://x401.proof.com/spec/#verifier-processing-rules). ```ts @@ -130,25 +95,20 @@ const artifact = verifier.decodeVPArtifact( request.headers["proof-presentation"], ); -const check = await verifier.verifyChallenge({ - value: artifact.challenge, - encryptor, - expectedVerifierId: "https://research.example.com", - expectedResource: "https://research.example.com/papers/medical-study-123", - expectedMethod: "GET", -}); +const result = artifact.response // inline { protocol, data } + ? artifact.response + : await fetchPresentation(artifact.presentation_uri!); // by reference -if (!check.ok) { +// validate `result` with your credential library + route policy, then: +if (!ok) { response.setHeader( "PROOF-RESPONSE", verifier.encodeErrorObject( - verifier.buildErrorObject({ error: "invalid_challenge" }), + verifier.buildErrorObject({ error: "invalid_presentation" }), ), ); return; } - -// verify artifact.vp_token with your credential library, then apply route policy ``` ## Agent @@ -157,8 +117,9 @@ See the full [agent processing rules](https://x401.proof.com/spec/#agent-process ### Read a Proof requirement (`PROOF-REQUIRED`) -`detectProofRequirement` reads the header, falling back to the embedded `` element. Take the -nonce and credential query to build your OpenID4VP request (out of scope for this package). +`detectProofRequirement` reads the header, falling back to the embedded `` element. +`getDigitalCredentialRequest` returns the Verifier-composed request unmodified — pass it straight to +the Digital Credentials API (or relay it). The agent MUST NOT alter it. ```ts import { agent } from "@proof.com/x401-node"; @@ -170,23 +131,29 @@ const requirement = agent.detectProofRequirement({ }); if (requirement) { - const nonce = agent.getNonce(requirement.payload); - const query = agent.getCredentialQuery(requirement.payload); // { scope } | { dcql_query } + const dcRequest = agent.getDigitalCredentialRequest(requirement.payload); + // const result = await navigator.credentials.get({ digital: dcRequest }); } ``` ### Present a Proof (`PROOF-PRESENTATION`) -Wrap the wallet's `vp_token` in a [VP Artifact](https://x401.proof.com/spec/#vp-artifact) and retry -the same route. +Wrap the `{ protocol, data }` presentation result in a +[VP Artifact](https://x401.proof.com/spec/#vp-artifact) and retry the same route. Use the +by-reference form for results too large for a header. ```ts const artifact = agent.buildVPArtifact({ - payload: requirement.payload, - agentId: "did:web:agent.example", - vpToken, + response: result, // { protocol, data } from the DC API + requestId: requirement.payload.request_id, }); +// or, by reference: +// const artifact = agent.buildVPArtifactReference({ +// presentationUri: "https://research.example.com/.well-known/x401/presentations/abc", +// expiresAt: "2026-05-06T18:50:00Z", +// }); + await fetch(url, { headers: { "PROOF-PRESENTATION": agent.encodeVPArtifact(artifact) }, }); @@ -213,4 +180,6 @@ const tokenHeader = agent.encodeTokenObject( await fetch(url, { headers: { "PROOF-PRESENTATION": tokenHeader } }); ``` +## Contributing + [Contribution guidelines for this project](CONTRIBUTING.md) diff --git a/package.json b/package.json index 03e0a22..a76e5e5 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ }, "devDependencies": { "@types/node": "^25.9.1", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "eslint": "^10.4.0", "eslint-plugin-unused-imports": "^4.4.1", "prettier": "^3.8.4", diff --git a/scripts/extract-normative.ts b/scripts/extract-normative.ts new file mode 100644 index 0000000..31ba9fb --- /dev/null +++ b/scripts/extract-normative.ts @@ -0,0 +1,99 @@ +/** + * Track the spec's RFC 2119 normative statements across spec revisions. + * + * Usage: + * node scripts/extract-normative.ts # diff spec vs the committed ledger + * node scripts/extract-normative.ts --list # print every normative statement + * node scripts/extract-normative.ts --update # rewrite the ledger to match the spec + * + * The ledger (spec/normative-ledger.json) is a snapshot of every normative statement at + * the pinned spec ref. After `sync-spec-fixtures.ts` pulls a new spec revision, the diff + * mode prints exactly which MUST/SHALL/REQUIRED statements were ADDED or REMOVED — the + * precise list to triage in spec/conformance.md before updating the code. Keyed by text + * (not line number) so reflowed prose still matches. Exits 1 when there is undated drift. + * + * Run scripts/sync-spec-fixtures.ts first; it caches spec/spec.md. + */ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const SPEC_MD = join(ROOT, "spec", "spec.md"); +const LEDGER = join(ROOT, "spec", "normative-ledger.json"); + +const KEYWORD = /\b(MUST NOT|MUST|SHALL NOT|SHALL|REQUIRED)\b/; +const HEADER_TOKENS = /PROOF-(REQUIRED|PRESENTATION|RESPONSE)/g; + +/** Whitespace-insensitive key so reflowed prose still matches across revisions. */ +function keyOf(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function normativeStatements(md: string): string[] { + const out: string[] = []; + let inFence = false; + for (const raw of md.split("\n")) { + const text = raw.trim(); + if (text.startsWith("```")) { + inFence = !inFence; + continue; + } + if (inFence || !text) continue; + // Strip the header field names so "PROOF-REQUIRED" stops matching "REQUIRED". + if (KEYWORD.test(text.replace(HEADER_TOKENS, ""))) out.push(keyOf(text)); + } + return [...new Set(out)].sort(); +} + +function main(): void { + const current = normativeStatements(readFileSync(SPEC_MD, "utf8")); + + if (process.argv.includes("--list")) { + for (const s of current) console.log(s); + console.log(`\n${current.length} normative statement(s).`); + return; + } + + if (process.argv.includes("--update")) { + writeFileSync(LEDGER, JSON.stringify(current, null, 2) + "\n", "utf8"); + console.log(`Ledger updated: ${current.length} normative statement(s).`); + return; + } + + if (!existsSync(LEDGER)) { + console.error("No ledger. Run: node scripts/extract-normative.ts --update"); + process.exitCode = 1; + return; + } + + const ledger: string[] = JSON.parse(readFileSync(LEDGER, "utf8")); + const prev = new Set(ledger); + const now = new Set(current); + const added = current.filter((s) => !prev.has(s)); + const removed = ledger.filter((s) => !now.has(s)); + + console.log( + `Spec has ${current.length} normative statement(s); ledger has ${ledger.length}.`, + ); + if (added.length === 0 && removed.length === 0) { + console.log("No drift. Every normative statement is accounted for."); + return; + } + if (added.length > 0) { + console.log( + `\nADDED (${added.length}) — triage each in spec/conformance.md:`, + ); + for (const s of added) console.log(` + ${s}`); + } + if (removed.length > 0) { + console.log(`\nREMOVED (${removed.length}) — drop stale handling/tests:`); + for (const s of removed) console.log(` - ${s}`); + } + console.log( + "\nAfter triaging, run: node scripts/extract-normative.ts --update", + ); + process.exitCode = 1; +} + +main(); diff --git a/scripts/sync-spec-fixtures.ts b/scripts/sync-spec-fixtures.ts new file mode 100644 index 0000000..0680e6e --- /dev/null +++ b/scripts/sync-spec-fixtures.ts @@ -0,0 +1,155 @@ +/** + * Sync spec-authored ground truth into `spec/`. + * + * Fetches `spec.md` from the proof/x401 repo at a pinned git ref, extracts the + * Appendix C JSON Schema and every JSON example block, classifies them by content, + * and writes them to `spec/fixtures/`. Also refreshes `spec/SPEC_SOURCE.json`. + * + * Usage: + * node scripts/sync-spec-fixtures.ts [] + * + * The ref defaults to the value recorded in spec/SPEC_SOURCE.json. Requires the + * GitHub CLI (`gh`) to be installed and authenticated. + * + * This script is the reproducible step in the spec-upgrade runbook (spec/UPGRADING.md): + * it pins the fixtures to an exact spec commit so the conformance tests check the code + * against spec text, not a paraphrase of it. + */ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO = "proof/x401"; +const SPEC_PATH = "spec.md"; +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const SPEC_DIR = join(ROOT, "spec"); +const FIXTURES_DIR = join(SPEC_DIR, "fixtures"); +const SOURCE_FILE = join(SPEC_DIR, "SPEC_SOURCE.json"); + +interface SpecSource { + repo: string; + ref: string; + branch: string; + version: string; + spec_url: string; + schema_url: string; + fetched_at: string; +} + +function readSource(): SpecSource { + return JSON.parse(readFileSync(SOURCE_FILE, "utf8")) as SpecSource; +} + +function fetchSpec(ref: string): string { + const b64 = execFileSync( + "gh", + [ + "api", + `repos/${REPO}/contents/${SPEC_PATH}?ref=${ref}`, + "--jq", + ".content", + ], + { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, + ); + return Buffer.from(b64, "base64").toString("utf8"); +} + +/** Extract the bodies of every fenced ```json block, in document order. */ +function jsonBlocks(md: string): string[] { + const blocks: string[] = []; + const re = /```json\n([\s\S]*?)```/g; + let m: RegExpExecArray | null; + while ((m = re.exec(md)) !== null) { + if (m[1] !== undefined) blocks.push(m[1].trim()); + } + return blocks; +} + +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function write(name: string, value: unknown): void { + writeFileSync( + join(FIXTURES_DIR, name), + JSON.stringify(value, null, 2) + "\n", + "utf8", + ); + console.log(` wrote spec/fixtures/${name}`); +} + +function main(): void { + const source = readSource(); + const ref = process.argv[2] ?? source.ref; + console.log(`Fetching ${REPO}:${SPEC_PATH} at ${ref} ...`); + const md = fetchSpec(ref); + + // Cache the raw spec so extract-normative.ts and conformance review run offline. + mkdirSync(SPEC_DIR, { recursive: true }); + writeFileSync(join(SPEC_DIR, "spec.md"), md, "utf8"); + console.log(" wrote spec/spec.md"); + + rmSync(FIXTURES_DIR, { recursive: true, force: true }); + mkdirSync(FIXTURES_DIR, { recursive: true }); + + const parsed = jsonBlocks(md).flatMap((raw) => { + try { + return [JSON.parse(raw) as unknown]; + } catch { + return []; // skip non-parseable blocks (e.g. truncated "..." JARs) + } + }); + + let payloads = 0; + let vpArtifacts = 0; + let oid4vpRequests = 0; + let schemaFound = false; + + for (const obj of parsed) { + if (!isObject(obj)) continue; + if ( + typeof obj["$schema"] === "string" && + obj["$schema"].includes("json-schema.org") && + obj["title"] !== undefined + ) { + write("request.schema.json", obj); + schemaFound = true; + } else if (obj["scheme"] === "x401") { + if (obj["presentation_requirements"] !== undefined) { + write(`payload-${++payloads}.json`, obj); + } else if (obj["error"] !== undefined) { + write("error-object.json", obj); + } else if (obj["access_token"] !== undefined) { + write("token-object.json", obj); + } + } else if ( + obj["response"] !== undefined || + obj["presentation_uri"] !== undefined + ) { + write(`vp-artifact-${++vpArtifacts}.json`, obj); + } else if (obj["response_type"] === "vp_token") { + // Informative: the decoded OpenID4VP request the Verifier signs into the JAR. + write(`openid4vp-request-${++oid4vpRequests}.json`, obj); + } + } + + if (!schemaFound) { + throw new Error("Appendix C JSON Schema not found in spec.md."); + } + if (payloads === 0) { + throw new Error("No x401 payload examples found in spec.md."); + } + + const updated: SpecSource = { + ...source, + ref, + fetched_at: new Date().toISOString(), + }; + writeFileSync(SOURCE_FILE, JSON.stringify(updated, null, 2) + "\n", "utf8"); + console.log( + `Done. ${payloads} payload(s), ${vpArtifacts} VP artifact(s), ${oid4vpRequests} OID4VP request(s).`, + ); +} + +main(); diff --git a/spec/SPEC_SOURCE.json b/spec/SPEC_SOURCE.json new file mode 100644 index 0000000..6968c95 --- /dev/null +++ b/spec/SPEC_SOURCE.json @@ -0,0 +1,9 @@ +{ + "repo": "proof/x401", + "ref": "c80fb9907baf8fa7158354e37d135b0f136f6715", + "branch": "dc-ification", + "version": "0.2.0", + "spec_url": "https://x401.proof.com/spec", + "schema_url": "https://x401.id/spec/schemas/request.json", + "fetched_at": "2026-06-23T10:19:19.399Z" +} diff --git a/spec/UPGRADING.md b/spec/UPGRADING.md new file mode 100644 index 0000000..17f8669 --- /dev/null +++ b/spec/UPGRADING.md @@ -0,0 +1,84 @@ +# Upgrading this SDK to a new x401 spec revision + +A repeatable loop: read the spec diff → re-pin spec-authored fixtures → see exactly which +normative statements changed → update the code → let the harness prove the code matches the +spec, not a paraphrase of it. Requires the GitHub CLI (`gh`) authenticated against `proof/x401`. + +## The harness (what does the checking) + +| Artifact | Role | +| ----------------------------------- | -------------------------------------------------------------------------- | +| `spec/SPEC_SOURCE.json` | Pins the exact spec repo + git ref the fixtures came from | +| `spec/spec.md` | Cached spec text at that ref (so normative checks run offline) | +| `spec/fixtures/request.schema.json` | Appendix C JSON Schema, extracted verbatim | +| `spec/fixtures/*.json` | Every JSON example from the spec (payloads, VP artifacts, error/token) | +| `spec/normative-ledger.json` | Snapshot of every MUST/SHALL/REQUIRED statement at the ref | +| `spec/conformance.md` | Human map: each in-scope requirement → code + test; out-of-scope rationale | +| `scripts/sync-spec-fixtures.ts` | Fetches spec at a ref; rewrites `spec.md`, fixtures, `SPEC_SOURCE.json` | +| `scripts/extract-normative.ts` | Diffs spec vs ledger; lists ADDED/REMOVED statements to triage | +| `tests/spec-schema.test.ts` | Validates payloads (and `buildPayload` output) against Appendix C | +| `tests/spec-fixtures.test.ts` | Parses + round-trips every spec example fixture | +| `tests/x401.test.ts` | Hand-written unit + negative cases for the current wire shapes | + +## Steps + +1. **Read the diff in full** — do not summarize away details: + + ```sh + gh pr diff --repo proof/x401 # or: gh api repos/proof/x401/compare/... + ``` + +2. **Re-pin the fixtures** to the new ref (PR head SHA, or the merged `main` SHA once merged): + + ```sh + node scripts/sync-spec-fixtures.ts + ``` + + This rewrites `spec/spec.md`, `spec/fixtures/*`, and the `ref`/`fetched_at` in `SPEC_SOURCE.json`. + Also update `branch`/`version` in `SPEC_SOURCE.json` by hand if they changed. + +3. **See what changed normatively:** + + ```sh + node scripts/extract-normative.ts # prints ADDED / REMOVED vs the ledger + ``` + + Triage each ADDED statement in `spec/conformance.md` (enforce it + cite the test, or mark it + out of scope with the responsible layer). Drop handling/tests for REMOVED statements. Then: + + ```sh + node scripts/extract-normative.ts --update + ``` + +4. **Update the code** in dependency order: + `src/constants.ts` → `src/types.ts` → `src/validate.ts` → `src/agent.ts` / `src/verifier.ts` + → `src/index.ts`. Keep wire fields snake_case; carry externally-signed/opaque blobs opaque. + +5. **Run the harness:** + + ```sh + yarn test + ``` + + `spec-schema` + `spec-fixtures` fail loudly if a field name, enum value, required/optional, or + object shape drifts from the spec's own schema and examples. + +6. **Adversarial review** — have an independent reviewer (subagent or person) read the spec diff + against the changed `src/` files and try to _refute_ the implementation: wrong field names / + enum values / `version`; VP Artifact one-of and by-reference semantics; and any straggler of a + removed concept left behind in `src/`. Resolve every confirmed finding. + +7. **Full gate:** + + ```sh + yarn check-all # format, lint, typecheck, test, publint + ``` + +8. **Publishing** is separate and gated — see `CLAUDE.md` › Publishing. Do not bump the npm + package version or release without explicit confirmation. + +## Hard rules that constrain every upgrade + +From `CLAUDE.md`: only runtime dep is `@owf/identity-common`; never verify credentials here; do +not build/sign the OpenID4VP request or wallet transport; no `eslint-disable`/`@ts-ignore`; +`engines.node >= 22`. Schema/test tooling (`ajv`, `ajv-formats`) is **devDependencies** only. diff --git a/spec/conformance.md b/spec/conformance.md new file mode 100644 index 0000000..2d01070 --- /dev/null +++ b/spec/conformance.md @@ -0,0 +1,61 @@ +# x401 conformance map + +Maps the spec's RFC 2119 normative statements to where this library enforces them — or +records why they are out of scope. The goal is to make "did we miss a MUST?" answerable. + +- **Source of truth:** `spec/spec.md` at the ref in `spec/SPEC_SOURCE.json` (currently + x401 **0.2.0**, proof/x401 `dc-ification`). +- **Drift detection:** `spec/normative-ledger.json` snapshots every normative statement. + After `node scripts/sync-spec-fixtures.ts`, run `node scripts/extract-normative.ts` to see + what was **added/removed** since the ledger. Triage new statements here, then + `node scripts/extract-normative.ts --update`. +- **Scope of this library:** it produces, encodes, decodes, and structurally validates the + x401 **wire objects**. It does **not** verify credentials, validate presentation bindings, + sign/compose the OpenID4VP request, perform the DC API call, or make HTTP/transport + decisions. Those statements are marked out of scope with the responsible layer. + +## In scope — enforced or produced by this library + +| Spec requirement | Where | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scheme` MUST be `"x401"` (payload, error, token) | `validate.ts` `parseX401Payload` / `parseX401ErrorObject` / `parseX401TokenObject`; builders in `verifier.ts`/`agent.ts` set the constant. Tests: `x401.test.ts`, `spec-fixtures.test.ts` | +| `version` REQUIRED | `validate.ts` (all three parsers); `X401_VERSION` constant | +| `presentation_requirements` REQUIRED; `requests` a non-empty array; each `protocol` is `openid4vp-v1-signed`/`openid4vp-v1-unsigned`; each `data` an object | `validate.ts` `parseX401Payload`, `verifier.ts` `buildPayload`. Tests: `spec-schema.test.ts` (Appendix C schema), `x401.test.ts` | +| `oauth` REQUIRED; `oauth.token_endpoint` REQUIRED | `validate.ts` `parseX401Payload`. Tests: `spec-schema.test.ts`, `x401.test.ts` | +| Payload encoded value MUST be base64url UTF-8 JSON (RFC 4648 §5, no padding); decoded MUST be a single JSON object | `encoding.ts` (`@owf/identity-common` base64url) | +| MUST NOT combine multiple objects in one proof header via commas/lists; comma value MUST be treated as invalid | `encoding.ts` `decodeProofHeader` comma guard. Test: `x401.test.ts` | +| VP Artifact MUST contain exactly one of `response` / `presentation_uri` | `validate.ts` `parseVPArtifact`. Tests: `x401.test.ts` (both/neither), `spec-fixtures.test.ts` | +| `response` is the `{ protocol, data }` DC API result | `validate.ts` `parseVPArtifact`; `agent.ts` `buildVPArtifact` | +| `presentation_uri` MUST be an `https` URL | `validate.ts` `parseVPArtifact`. Test: `x401.test.ts` (non-https rejected) | +| Token Object `token_type` MUST be `"Bearer"`; `access_token` REQUIRED | `validate.ts` `parseX401TokenObject`; `agent.ts` `buildTokenObject` | +| Error Object `error` REQUIRED | `validate.ts` `parseX401ErrorObject` | +| Token-exchange fixed params (`grant_type`, `subject_token_type`, Bearer) MUST NOT be repeated in the payload | not present in the payload type; set only on the form by `agent.ts` `buildTokenExchangeForm`; verified by `verifier.ts` `parseTokenExchange`. Test: `x401.test.ts` | +| Embedded ``: tag `data`, `value="application/json;x401=proof-required"`, `hidden`, single JSON object that is a valid payload and MUST include a `$schema` member = `https://x401.id/spec/schemas/request.json` | `verifier.ts` `embedHtmlData`; `agent.ts` `detectProofRequirement` + `parseX401Payload`. Test: `x401.test.ts` (embedded round-trip) | +| Embedded object subject to the same structural validation as a header payload | `agent.ts` `detectProofRequirement` runs `parseX401Payload`. Test: `x401.test.ts` | +| Agent MUST NOT modify any entry in `presentation_requirements` | `agent.ts` `getDigitalCredentialRequest` returns it unmodified; library never mutates it | + +## Out of scope — responsibility of another layer + +These normative statements are real but fall outside an encode/decode/validate library. + +- **Verifier proof validation & crypto** (the "The Verifier MUST:" list, Verifier Binding, + nonce freshness/replay, dereferencing a `presentation_uri`, unique-URI issuance, issuer + trust enforcement, `trusted_authorities`): the verifier application. This library does not + verify presentations or sign requests (`CLAUDE.md` Hard Rules 1–3). +- **Credential verification** (issuer trust, status, revocation, claim satisfaction): + `@proof.com/proof-vc-common`. `vp_token`/`response.data` is opaque here. +- **Agent runtime / transport** (obtaining a presentation via `navigator.credentials.get`, + relaying, remote fulfillment, retrying the route): the Agent application. +- **OpenID4VP request composition/signing** (the JAR, `client_id`, `expected_origins`, + `nonce`, `dcql_query`, `exp`): the verifier; carried opaque in `data`. +- **HTTP semantics** (status-code independence, `WWW-Authenticate` non-use, `402` payment + separation, `Cache-Control`/`Vary`, CORS exposure): the HTTP server/deployment. +- **Verification Token issuance, scope, binding, holder identity**; **Agent binding** + (OPTIONAL): the verifier/deployment. + +## Known coverage gap + +Only the **PROOF-REQUIRED payload** has an official JSON Schema (Appendix C). The VP Artifact, +Error Object, and Token Object are checked against extracted spec examples + these parsers, not a +published schema. If the spec later publishes schemas for those objects, add them to +`spec/fixtures/` via `sync-spec-fixtures.ts` and extend `spec-schema.test.ts`. diff --git a/spec/fixtures/error-object.json b/spec/fixtures/error-object.json new file mode 100644 index 0000000..ffe5864 --- /dev/null +++ b/spec/fixtures/error-object.json @@ -0,0 +1,7 @@ +{ + "scheme": "x401", + "version": "0.2.0", + "error": "invalid_presentation", + "error_description": "The presentation did not satisfy the route proof requirement.", + "request_id": "proof-template-financial-customer-v1" +} diff --git a/spec/fixtures/openid4vp-request-1.json b/spec/fixtures/openid4vp-request-1.json new file mode 100644 index 0000000..ed23808 --- /dev/null +++ b/spec/fixtures/openid4vp-request-1.json @@ -0,0 +1,36 @@ +{ + "response_type": "vp_token", + "response_mode": "dc_api", + "client_id": "x509_san_dns:bank.example.com", + "expected_origins": [ + "https://bank.example.com" + ], + "nonce": "uX7Vq3mZJH6MeN0qz2L7SQ", + "dcql_query": { + "credentials": [ + { + "id": "financial_customer", + "format": "jwt_vc_json", + "meta": { + "type_values": [ + "FinancialCustomerCredential" + ] + }, + "claims": [ + { + "path": [ + "credentialSubject", + "assurance_level" + ], + "values": [ + "VC-AL2", + "VC-AL3" + ] + } + ] + } + ] + }, + "client_metadata": {}, + "exp": 1746557100 +} diff --git a/spec/fixtures/openid4vp-request-2.json b/spec/fixtures/openid4vp-request-2.json new file mode 100644 index 0000000..240d8ac --- /dev/null +++ b/spec/fixtures/openid4vp-request-2.json @@ -0,0 +1,35 @@ +{ + "response_type": "vp_token", + "response_mode": "dc_api", + "client_id": "x509_san_dns:bank.example.com", + "expected_origins": [ + "https://bank.example.com" + ], + "nonce": "uX7Vq3mZJH6MeN0qz2L7SQ", + "dcql_query": { + "credentials": [ + { + "id": "financial_customer", + "format": "jwt_vc_json", + "meta": { + "type_values": [ + "FinancialCustomerCredential" + ] + }, + "claims": [ + { + "path": [ + "credentialSubject", + "assurance_level" + ], + "values": [ + "VC-AL2", + "VC-AL3" + ] + } + ] + } + ] + }, + "exp": 1746557100 +} diff --git a/spec/fixtures/payload-1.json b/spec/fixtures/payload-1.json new file mode 100644 index 0000000..042b3c6 --- /dev/null +++ b/spec/fixtures/payload-1.json @@ -0,0 +1,10 @@ +{ + "scheme": "x401", + "version": "0.2.0", + "presentation_requirements": {}, + "oauth": {}, + "trust_establishment": "https://...", + "request_id": "...", + "satisfied_requirements": [], + "payment": {} +} diff --git a/spec/fixtures/payload-2.json b/spec/fixtures/payload-2.json new file mode 100644 index 0000000..6e485a9 --- /dev/null +++ b/spec/fixtures/payload-2.json @@ -0,0 +1,21 @@ +{ + "scheme": "x401", + "version": "0.2.0", + "presentation_requirements": { + "requests": [ + { + "protocol": "openid4vp-v1-signed", + "data": { + "request": "eyJhbGciOiJFUzI1NiIsInR5cCI6Im9hdXRoLWF1dGh6LXJlcStqd3QifQ..." + } + } + ] + }, + "oauth": { + "token_endpoint": "https://bank.example.com/oauth/token" + }, + "request_id": "proof-template-financial-customer-v1", + "satisfied_requirements": [ + "urn:example:x401:satisfaction:financial-customer:v1" + ] +} diff --git a/spec/fixtures/payload-3.json b/spec/fixtures/payload-3.json new file mode 100644 index 0000000..f8caafd --- /dev/null +++ b/spec/fixtures/payload-3.json @@ -0,0 +1,22 @@ +{ + "scheme": "x401", + "version": "0.2.0", + "presentation_requirements": { + "requests": [ + { + "protocol": "openid4vp-v1-signed", + "data": { + "request": "eyJhbGciOiJFUzI1NiIsInR5cCI6Im9hdXRoLWF1dGh6LXJlcStqd3QifQ..." + } + } + ] + }, + "oauth": { + "token_endpoint": "https://bank.example.com/oauth/token" + }, + "trust_establishment": "https://bank.example.com/.well-known/x401/trust/financial-customer-v1", + "request_id": "proof-template-financial-customer-v1", + "satisfied_requirements": [ + "urn:example:x401:satisfaction:financial-customer:v1" + ] +} diff --git a/spec/fixtures/payload-4.json b/spec/fixtures/payload-4.json new file mode 100644 index 0000000..525c7b4 --- /dev/null +++ b/spec/fixtures/payload-4.json @@ -0,0 +1,17 @@ +{ + "scheme": "x401", + "version": "0.2.0", + "presentation_requirements": { + "requests": [ + { + "protocol": "openid4vp-v1-signed", + "data": { + "request": "eyJhbGciOiJFUzI1NiIsInR5cCI6Im9hdXRoLWF1dGh6LXJlcStqd3QifQ..." + } + } + ] + }, + "oauth": { + "token_endpoint": "https://bank.example.com/oauth/token" + } +} diff --git a/spec/fixtures/request.schema.json b/spec/fixtures/request.schema.json new file mode 100644 index 0000000..aa82e14 --- /dev/null +++ b/spec/fixtures/request.schema.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://x401.id/spec/schemas/request.json", + "title": "x401 Proof Requirement Payload", + "description": "Schema for an x401 proof requirement payload as defined by the x401 specification.", + "type": "object", + "required": [ + "scheme", + "version", + "presentation_requirements", + "oauth" + ], + "properties": { + "$schema": { + "type": "string", + "format": "uri", + "description": "Optional informational marker. When the payload is embedded in HTML as a element, this SHOULD be set to https://x401.id/spec/schemas/request.json so that content processors can recognize the object as an x401 proof requirement." + }, + "scheme": { + "type": "string", + "const": "x401", + "description": "MUST be the string \"x401\"." + }, + "version": { + "type": "string", + "description": "The x401 payload version." + }, + "presentation_requirements": { + "type": "object", + "required": [ + "requests" + ], + "description": "The composed Digital Credentials request (a DigitalCredentialRequestOptions value), usable as the digital member of navigator.credentials.get().", + "properties": { + "requests": { + "type": "array", + "minItems": 1, + "description": "Digital Credentials request entries. Every entry MUST be a valid OpenID4VP request for the DC API.", + "items": { + "type": "object", + "required": [ + "protocol", + "data" + ], + "properties": { + "protocol": { + "type": "string", + "enum": [ + "openid4vp-v1-signed", + "openid4vp-v1-unsigned" + ], + "description": "The DC API protocol identifier. openid4vp-v1-signed is RECOMMENDED; openid4vp-v1-unsigned is permitted. See Verifier Binding." + }, + "data": { + "type": "object", + "description": "Protocol-specific request data. For openid4vp-v1-signed, an object carrying the signed OpenID4VP request (e.g. { \"request\": \"\" }); for openid4vp-v1-unsigned, the OpenID4VP request parameters directly.", + "properties": { + "request": { + "type": "string", + "description": "The signed OpenID4VP request (a JWT-Secured Authorization Request); present for openid4vp-v1-signed." + } + } + } + } + } + } + } + }, + "oauth": { + "type": "object", + "required": [ + "token_endpoint" + ], + "properties": { + "token_endpoint": { + "type": "string", + "format": "uri", + "description": "OAuth 2.0 token endpoint where the Agent can exchange a VP Artifact for a Verification Token." + }, + "audience": { + "type": "string", + "description": "Optional OAuth token exchange audience value the Agent should request." + }, + "resource": { + "type": "string", + "format": "uri", + "description": "Optional OAuth token exchange resource value the Agent should request." + } + }, + "additionalProperties": false + }, + "trust_establishment": { + "type": "string", + "format": "uri", + "description": "Optional acquisition and discovery hint: HTTPS URL for a DIF Credential Trust Establishment document. Issuer enforcement is governed by the signed request (DCQL trusted_authorities), not by this member." + }, + "request_id": { + "type": "string", + "description": "Optional Agent-visible hint: a stable verifier-defined identifier for the proof template." + }, + "satisfied_requirements": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional Agent-visible reuse hint: stable verifier-defined identifiers for the reusable proof requirements this proof would satisfy." + }, + "payment": { + "type": "object", + "description": "Informational hint that payment is additionally required. Does not replace 402 Payment Required.", + "properties": { + "required": { + "type": "boolean", + "description": "Whether payment is additionally required." + }, + "scheme_hint": { + "type": "string", + "description": "Hint naming the expected payment protocol." + }, + "notes": { + "type": "string", + "description": "Human-readable notes." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/token-object.json b/spec/fixtures/token-object.json new file mode 100644 index 0000000..ade17e2 --- /dev/null +++ b/spec/fixtures/token-object.json @@ -0,0 +1,6 @@ +{ + "scheme": "x401", + "version": "0.2.0", + "token_type": "Bearer", + "access_token": "" +} diff --git a/spec/fixtures/vp-artifact-1.json b/spec/fixtures/vp-artifact-1.json new file mode 100644 index 0000000..c1e7aed --- /dev/null +++ b/spec/fixtures/vp-artifact-1.json @@ -0,0 +1,7 @@ +{ + "request_id": "proof-template-financial-customer-v1", + "response": { + "protocol": "openid4vp-v1-signed", + "data": "" + } +} diff --git a/spec/fixtures/vp-artifact-2.json b/spec/fixtures/vp-artifact-2.json new file mode 100644 index 0000000..c1e7aed --- /dev/null +++ b/spec/fixtures/vp-artifact-2.json @@ -0,0 +1,7 @@ +{ + "request_id": "proof-template-financial-customer-v1", + "response": { + "protocol": "openid4vp-v1-signed", + "data": "" + } +} diff --git a/spec/fixtures/vp-artifact-3.json b/spec/fixtures/vp-artifact-3.json new file mode 100644 index 0000000..2e6855e --- /dev/null +++ b/spec/fixtures/vp-artifact-3.json @@ -0,0 +1,5 @@ +{ + "request_id": "proof-template-financial-customer-v1", + "presentation_uri": "https://bank.example.com/.well-known/x401/presentations/abc123", + "expires_at": "2026-05-06T18:50:00Z" +} diff --git a/spec/fixtures/vp-artifact-4.json b/spec/fixtures/vp-artifact-4.json new file mode 100644 index 0000000..c1e7aed --- /dev/null +++ b/spec/fixtures/vp-artifact-4.json @@ -0,0 +1,7 @@ +{ + "request_id": "proof-template-financial-customer-v1", + "response": { + "protocol": "openid4vp-v1-signed", + "data": "" + } +} diff --git a/spec/fixtures/vp-artifact-5.json b/spec/fixtures/vp-artifact-5.json new file mode 100644 index 0000000..2e6855e --- /dev/null +++ b/spec/fixtures/vp-artifact-5.json @@ -0,0 +1,5 @@ +{ + "request_id": "proof-template-financial-customer-v1", + "presentation_uri": "https://bank.example.com/.well-known/x401/presentations/abc123", + "expires_at": "2026-05-06T18:50:00Z" +} diff --git a/spec/normative-ledger.json b/spec/normative-ledger.json new file mode 100644 index 0000000..f680749 --- /dev/null +++ b/spec/normative-ledger.json @@ -0,0 +1,85 @@ +[ + "1. MUST include `PROOF-REQUIRED: ` when proof is required or advertised.", + "1. MUST make each entry a valid OpenID4VP request for the Digital Credentials API, using `protocol: \"openid4vp-v1-signed\"` (RECOMMENDED) or `protocol: \"openid4vp-v1-unsigned\"`.", + "1. MUST treat the parsed JSON object as an x401 payload subject to the same structural validation and composed-request processing defined elsewhere in this specification.", + "1. MUST treat the response as a proof requirement.", + "1. MUST use the tag name `data`.", + "1. The Agent MUST NOT modify any entry in `presentation_requirements`. The request signature binds its contents, so any modification invalidates it.", + "1. The `presentation_uri` MUST be an `https` URL.", + "1. when the deployment binds the Agent, MUST be issued to the [[ref: Agent Identifier]] bound during the retry, and MUST NOT rely on the credential subject as the token holder identity unless the credential subject is also the Agent;", + "10. MUST evaluate issuer trust, status, revocation, and policy constraints independently of any Agent-side interpretation of the Issuer Trust List.", + "10. MUST retry the same route that produced the x401 proof requirement with one of:", + "11. MUST NOT replace an existing application `Authorization` credential with an x401 Verification Token unless the deployment explicitly defines the returned token as valid for that route's ordinary authorization processing.", + "11. MUST accept a VP Artifact in a `PROOF-PRESENTATION` request header for protected-route retry, in both its inline and by-reference forms.", + "12. MUST treat a `PROOF-RESPONSE` carrying an x401 Error Object as an x401 proof failure for the route-scoped proof attempt, regardless of the HTTP status code.", + "13. MUST validate Verification Tokens on protected-route retry according to token scope, audience, expiration, any Agent binding, and satisfied requirement metadata, whether the token arrives in `Authorization` or as an x401 Token Object in `PROOF-PRESENTATION`.", + "14. MUST bind any Verification Token carried in `PROOF-PRESENTATION` to the existing application caller, credential, client, key, or Agent Identifier required by the protected route when an `Authorization` header is also present.", + "16. MUST use `402 Payment Required` separately if payment is required and remains unsatisfied.", + "2. MUST be scoped to the Verifier audience and to the route, policy, action, resource, or resource class for which proof was accepted;", + "2. MUST extract the `PROOF-REQUIRED` field value and base64url-decode it as a UTF-8 JSON [[ref: x401 Payload]].", + "2. MUST include a valid base64url-encoded x401 payload in `PROOF-REQUIRED`.", + "2. MUST set the `value` attribute to the MIME-type expression `application/json;x401=proof-required`. The `x401` parameter identifies the embedded carrier and signals the role of the element's text content.", + "2. The Verifier MUST issue a unique `presentation_uri` for each presentation and MUST NOT reuse a URI value across presentations. Uniqueness per presentation is what lets the Verifier treat the reference as single-use and bind it to one retry.", + "3. MUST expire, and SHOULD be short-lived;", + "3. MUST set the `hidden` attribute so the element is not visually rendered.", + "3. MUST use an HTTP status code appropriate for the overall response and MUST NOT rely on the status code alone to convey x401 proof state.", + "3. MUST validate the decoded payload structure and process the `proof` object.", + "3. The Agent MUST arrange to acquire the [[ref: Presentation Result]] returned for the request, whether it invokes the request itself, relays it, or acquires a remotely generated result.", + "4. MUST contain a single JSON object as its text content. The JSON object MUST be a valid x401 payload as defined in [x401 Payload](#x401-payload), and MUST include a `$schema` member whose value is the JSON Schema URL for the x401 request object, `https://x401.id/spec/schemas/request.json`. The `$schema` member is an informational marker that allows AI scrapers, content processors, and validators that retain only the JSON object to recognize it as an x401 proof requirement without prior knowledge of the surrounding HTML carrier.", + "4. MUST include a `presentation_requirements` whose entries are valid OpenID4VP requests for the DC API, using `openid4vp-v1-signed` (RECOMMENDED) or `openid4vp-v1-unsigned`.", + "4. MUST treat `presentation_requirements` as the Verifier-composed [[ref: Digital Credentials Request]] and MUST NOT modify any of its entries.", + "5. MUST obtain a [[ref: Presentation Result]] for `presentation_requirements` by invoking it through a native credential method, relaying it to a Wallet or remote service, or acquiring a remotely generated result.", + "5. SHOULD use signed requests and set their `client_id` and `expected_origins`; when using unsigned requests, MUST account for the weaker binding described in [Verifier Binding](#verifier-binding).", + "6. MUST include OAuth token exchange metadata in `oauth`.", + "8. MUST NOT enumerate verifier-approved issuers inline in the x401 payload.", + "8. MUST NOT treat any Agent-side interpretation of the Issuer Trust List as proof of verifier acceptance.", + "9. MUST package the presentation result as a VP Artifact, inline or as a [[ref: Presentation Reference]].", + "9. MUST validate presentations according to the proof validation rules in this specification and the credential format rules it relies upon, dereferencing a [[ref: Presentation Reference]] when one is supplied.", + "A VP Artifact MUST contain exactly one of `response` or `presentation_uri`.", + "A Verifier MAY use the validated signing key, key directory authority, or derived service identity as the Agent Identifier, or as evidence that maps to an Agent Identifier. The Verifier MUST still validate the Wallet presentation binding for the request mode, the credential query satisfaction, issuer trust, token scope, and payment boundary. Web Bot Auth identifies the calling automation or service; it does not by itself prove the credential subject, satisfy the credential query, or prove end-user delegation.", + "A Verifier that emits embedded `` elements MUST still enforce proof on the protected resource through the normal `PROOF-REQUIRED` / `PROOF-PRESENTATION` exchange. Embedding a requirement in HTML is informational disclosure and does not by itself grant access.", + "A `` element placed at the document level applies to the page as a whole and SHOULD be used as a body-side mirror of the route-scoped `PROOF-REQUIRED` header so that header-blind clients can still discover the requirement. A response MAY include multiple `