diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..3a02d32 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist +spec/spec.md +spec/fixtures/ +spec/normative-ledger.json diff --git a/CLAUDE.md b/CLAUDE.md index 235f973..3e7f64c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,18 +1,22 @@ # @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). +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 +59,12 @@ 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/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..240da65 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,84 +36,44 @@ 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. +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 -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. - -```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 +import { verifier } from "@proof.com/x401-node"; -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 - -`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", + data: { request: signedOpenId4vpRequestJwt }, + }, + ], }, + oauth: { token_endpoint: "https://research.example.com/oauth/token" }, + 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 +`protocol` is `openid4vp-v1-signed` (RECOMMENDED) or `openid4vp-v1-unsigned`, and its `data` +carries the request you composed and signed. `trustEstablishment`, `requestId`, and +`satisfiedRequirements` are optional hints. -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)}`; @@ -120,9 +81,11 @@ const html = `
${verifier.embedHtmlData(payload)}`; ### 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 +93,19 @@ 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 + ? artifact.response + : await fetchPresentation(artifact.presentation_uri!); -if (!check.ok) { +if (!validatePresentation(result)) { 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 +114,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,21 +128,26 @@ 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 }); } ``` +If you're an intermediary relaying the request to a **remote handler** (which POSTs the result +back rather than invoking the DC API itself), add an `https` `return_uri` to the forwarded payload +with `agent.addReturnUri(payload, returnUri)`. Only a relaying intermediary sets this — never the +Verifier. + ### 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, + requestId: requirement.payload.request_id, }); await fetch(url, { @@ -192,6 +155,16 @@ await fetch(url, { }); ``` +Or, by reference: + +```ts +const artifact = agent.buildVPArtifactReference({ + presentationUri: + "https://research.example.com/.well-known/x401/presentations/abc", + expiresAt: "2026-05-06T18:50:00Z", +}); +``` + ### Exchange a Proof for a token Exchange the artifact for a reusable Verification Token via @@ -213,4 +186,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..24de614 --- /dev/null +++ b/spec/SPEC_SOURCE.json @@ -0,0 +1,9 @@ +{ + "repo": "proof/x401", + "ref": "4057cacc41e9e20547f6a6949946e9e1115b37d2", + "branch": "main", + "version": "0.2.0", + "spec_url": "https://x401.proof.com/spec", + "schema_url": "https://x401.id/spec/schemas/request.json", + "fetched_at": "2026-06-24T08:55:14.795Z" +} 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..0502975 --- /dev/null +++ b/spec/conformance.md @@ -0,0 +1,70 @@ +# 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 `main` — PR #15 merged). +- **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 | +| A relaying intermediary MUST add a `return_uri` member (an `https` URL) to the forwarded payload; the Verifier never sets it | `agent.ts` `addReturnUri` (https-validated; `buildPayload` never emits it); `validate.ts` `parseX401Payload` enforces https. Tests: `x401.test.ts` | + +## Out of scope — responsibility of another layer + +These normative statements are real but fall outside an encode/decode/validate library. + +- **Remote handler processing** (matching `requests[]` against held credentials, satisfying + `dcql_query` incl. `credential_sets`/`claim_sets`, Holder selection, producing the presentation, + POSTing it to `return_uri`): the remote wallet/handler. This SDK only adds and validates the + `return_uri` member; it does not act as a handler. +- **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`. This includes the + "Composing a Request for Both Native and Relayed Fulfillment" rules added when PR #15 merged + (self-contained request, `x5c`/resolvable `client_id`, carrying all inputs inside the request + object, transport members optional) — all verifier request-authoring concerns, not wire-object + structure. +- **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..058f99c --- /dev/null +++ b/spec/fixtures/payload-3.json @@ -0,0 +1,18 @@ +{ + "scheme": "x401", + "version": "0.2.0", + "presentation_requirements": { + "requests": [ + { + "protocol": "openid4vp-v1-signed", + "data": { + "request": "eyJ..." + } + } + ] + }, + "oauth": { + "token_endpoint": "https://bank.example.com/oauth/token" + }, + "return_uri": "https://mcp.example/x401/return/9f1c2a" +} diff --git a/spec/fixtures/payload-4.json b/spec/fixtures/payload-4.json new file mode 100644 index 0000000..f8caafd --- /dev/null +++ b/spec/fixtures/payload-4.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-5.json b/spec/fixtures/payload-5.json new file mode 100644 index 0000000..525c7b4 --- /dev/null +++ b/spec/fixtures/payload-5.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..9af2e9b --- /dev/null +++ b/spec/fixtures/request.schema.json @@ -0,0 +1,134 @@ +{ + "$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." + }, + "return_uri": { + "type": "string", + "format": "uri", + "description": "Optional. Added by a relaying intermediary (never by the Verifier) to tell a remote handler where to POST the presentation result. See Relayed Delivery to a Remote Handler." + }, + "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..5f9a76b --- /dev/null +++ b/spec/normative-ledger.json @@ -0,0 +1,94 @@ +[ + "1. MUST consider the `requests[]` entries whose `protocol` and credential formats it implements, and among those, MUST attempt to satisfy each entry's `dcql_query` — including any `credential_sets` / `claim_sets` alternatives within it — against the credentials available to it, with Holder selection where applicable. Which entry is used is the *outcome* of this matching, not a prior choice; an entry is usable only if a held credential satisfies its query.", + "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. For a satisfiable entry, MUST read the OpenID4VP request from its `data` — the claims of the signed request object for `openid4vp-v1-signed`, or the request parameters directly for `openid4vp-v1-unsigned` — and produce a presentation that satisfies that `dcql_query`, bound to its `nonce`. It honors `client_metadata` for accepted formats and any response-encryption key, and, for a signed request, binds the presentation's audience to the request's `client_id`.", + "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 make its identity and request-signing key resolvable from the request alone. The Verifier SHOULD embed its signing certificate chain in the request JWS `x5c` header, or use a `client_id` whose key material is publicly resolvable (for example a `did:` or `https:` scheme), so a handler with no prior relationship can verify the signature and confirm it matches `client_id` offline. A Verifier MUST NOT rely on a `client_id` scheme or key whose resolution depends on the invoking Web origin or on prior DC-API interaction, because neither exists on the relayed path.", + "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 carry every input a fulfiller needs inside the request object: the `nonce`, the `dcql_query` (including any `credential_sets` / `claim_sets` alternatives), the accepted formats and any response-encryption key in `client_metadata`, and the `exp`. A remote handler reads these directly from the request `data` — the signed request object's claims for a signed request — not from any API surface, so a value referenced only through the DC-API or a same-origin session is unavailable to it.", + "3. MUST expire, and SHOULD be short-lived;", + "3. MUST set the `hidden` attribute so the element is not visually rendered.", + "3. MUST treat the Digital Credentials API transport members as not applicable to relayed delivery: it does not return through `response_mode: dc_api`/`dc_api.jwt` (it returns to `return_uri` instead) and does not enforce `expected_origins` (there is no invoking Web origin).", + "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 NOT weaken or alter the `dcql_query` or `nonce`, and MUST deliver the resulting [[ref: Presentation Result]] to `return_uri`. If it can satisfy no entry, it returns no presentation and SHOULD signal that failure to the intermediary rather than returning a partial or substitute 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.", + "4. SHOULD still set the Digital Credentials API transport members — `response_mode` (`dc_api` / `dc_api.jwt`) and `expected_origins` — for the native path, but MUST NOT make correct processing depend on them. A remote handler treats them as not applicable (it returns to `return_uri` and there is no invoking origin to pre-authorize), so a request whose only Verifier binding is `expected_origins` degrades to `nonce`-only when relayed.", + "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 Verifier that may have its request fulfilled either natively — invoked through `navigator.credentials.get()` by a Wallet enforcing the Digital Credentials API — or by a remote handler that parses the request and generates a presentation manually MUST compose the request so it is self-contained and verifiable by a party with no prior relationship and no browser context. The governing test is: *could a Wallet that has never interacted with this Verifier verify the request and produce a correctly bound presentation from the request bytes alone?* A request composed only for the native path can silently fail this test even though a DC-API Wallet accepts it. To satisfy both paths, a Verifier:", + "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 `