From a1c4fcc4842d1c3597e200652ba0181b43f3d406 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 31 Jul 2026 11:33:43 +0200 Subject: [PATCH 1/3] change(ard): drop the trust-manifest signature; publish the catalogue unsigned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild of #123 on current main, which #121 conflicted with. The signature protected three static fields and was verified by a JWK Set on the same origin as the catalogue it vouched for, so it folded to exactly the compromise it existed to detect. It also left `entries` — where the MCP and A2A endpoint URLs live — unsigned. Removes host.trustManifest, the jwks _headers block and api-catalog entry, the sign:ard / check:ard scripts and the CI step. The spec page gains the reasoning: sign only when the verifying key is anchored independently of the signed document. Two files still need deleting; see the PR body. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 3 - .gitignore | 2 + CLAUDE.md | 61 +++++++++---------- docs/okf-recon.md | 5 ++ ops/notes/ard-adoption.md | 10 +++ package.json | 2 - public/.well-known/ai-catalog.json | 8 +-- public/.well-known/api-catalog | 5 -- public/_headers | 6 -- .../2026-07-23-ard-catalog-unsigned.md | 8 +++ .../agentic-resource-discovery.md | 17 ++++-- 11 files changed, 69 insertions(+), 58 deletions(-) create mode 100644 src/content/changelog/2026-07-23-ard-catalog-unsigned.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b658bde..73f9587e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,9 +37,6 @@ jobs: - name: Agent Skill drift check run: npm run check:skill - - name: ARD catalog signature check - run: npm run check:ard - - name: Type-check (Astro) run: npx astro check diff --git a/.gitignore b/.gitignore index 6cc644fb..cc13a796 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ pnpm-debug.log* .wrangler/ # ARD AI Catalog offline signing key — NEVER commit. Back up to secure storage. +# Retired with the ARD trust-manifest signature. Kept ignored so a leftover key +# from before the removal can never be committed by accident. .ard-signing-key.json # Claude diff --git a/CLAUDE.md b/CLAUDE.md index fa4166c4..b3885ddb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,37 +69,36 @@ These mirror `CONTRIBUTING.md`. Enforce them in your own writing and when review ## Architecture quick reference -| Path | Purpose | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/content/spec//.md` | Spec content. Edit here. | -| `src/content.config.ts` | Content collection schema. Edit if adding a field. | -| `src/content/considered/.md` | Hand-curated register of standards deliberately **not** specced, with the reason and what would reverse it. Rendered at `/considered/`. Not derived. | -| `src/lib/site.ts` | Site metadata + the canonical category list. | -| `src/layouts/BaseLayout.astro` | HTML shell, head, dialog for ⌘K search, Plausible (PROD only). | -| `src/layouts/SpecLayout.astro` | Spec page wrapper; emits TechArticle + BreadcrumbList JSON-LD; advertises Markdown alt via `markdownUrl`. | -| `src/components/HeadMeta.astro` | `` metadata, canonical, OG, Twitter, JSON-LD, RSS / sitemap / markdown alternates. | -| `src/components/SiteHeader.astro` | Header nav. Contains the ⌘K trigger. | -| `src/components/SiteFooter.astro` | Footer. Privacy / search links live here. | -| `src/pages/spec/[category]/[slug].astro` | The dynamic HTML route. | -| `src/pages/spec/[category]/[slug].md.ts` | The dynamic Markdown route. | -| `src/pages/llms.txt.ts`, `src/pages/llms-full.txt.ts`, `src/pages/rss.xml.ts` | Derived endpoints. | -| `src/lib/okf.ts`, `src/pages/okf/` | OKF bundle generator + the `.md` endpoints that emit the `/okf/` tree (concepts, per-directory `index.md`, `references/`, `log.md`, root `index.md`). Derived from the `spec` + `changelog` collections. | -| `astro-okf-tarball.mjs` (root) | Astro integration; in `astro:build:done` it walks `dist/okf/`, writes a reproducible POSIX ustar archive by hand (no tar dependency), gzips it to `dist/okf.tar.gz`. | -| `functions/_middleware.ts` | Cloudflare Pages middleware. Does `Accept: text/markdown` content negotiation on canonical spec URLs, on `/` (→ `/llms.txt`), and on `/checklist/` (→ `/checklist.md`), and calls `logBot()` so crawler hits land in the `AGENT_LOG` Analytics Engine dataset. | -| `functions/_shared/bot-detect.ts` | UA / `signature-agent` / `cf-verified` / `Accept: text/markdown` detection plus `writeDataPoint()` to the `AGENT_LOG` Analytics Engine binding. Never throws. | -| `functions/reports.ts` | `/reports` collector for the W3C Reporting API. Browsers POST CSP/COOP/COEP violation, deprecation, intervention, and crash reports here (named by the `Reporting-Endpoints` header in `_headers`); writes one aggregate row per report to the `REPORT_LOG` Analytics Engine dataset (`sw_report_log`). Deprecation/intervention reports triggered by third-party (browser-extension) scripts are dropped — only same-origin `sourceFile`s are recorded. POST-only, never throws, returns 204; stores no IP, no cookies, no URL query strings. | -| `functions/admin/stats.ts` | `/admin/stats` dashboard. Queries `sw_agent_log` (crawlers), `sw_mcp_log` (MCP/A2A), and `sw_report_log` (browser policy reports) via the Cloudflare Analytics Engine SQL API using `CF_ACCOUNT_ID` + `CF_ANALYTICS_TOKEN` Pages secrets. **Behind Cloudflare Access** — the function assumes the caller is already authenticated. | -| `public/admin-stats.js` | Tab + filter JS for `/admin/stats`. Extracted to a file because the CSP forbids inline scripts. | -| `public/_routes.json` | Cloudflare Pages routing manifest — excludes static assets from the Functions worker so bot logging only runs on HTML and well-known paths. | -| `wrangler.toml` (root) | Pages bindings — the `AGENT_LOG` (`sw_agent_log`) and `REPORT_LOG` (`sw_report_log`) Analytics Engine datasets. Pages itself is deployed via the Pages dashboard's Git integration. | -| `public/_headers` | Cloudflare response headers — strict CSP, HSTS, Permissions-Policy, Vary on .md, content types for well-known files, the discovery `Link` header. | -| `public/.well-known/` | Static well-known URIs (security.txt, change-password, api-catalog, mcp/server-card.json, agent-card.json for A2A discovery, agent-skills/index.json + agent-skills//SKILL.md per the Agent Skills Discovery RFC v0.2.0 — if you edit a SKILL.md, recompute its sha256 and update the `digest` in index.json; ai-catalog.json is the ARD AI Catalog whose `host.trustManifest` carries a detached ES256 JWS — **if you edit ai-catalog.json, re-run `npm run sign:ard` or the signature breaks** — with jwks.json publishing the public key). | -| `mcp/` | Cloudflare Worker exposing the spec at `mcp.specification.website`. Serves the MCP transport at `/mcp`, an A2A (Agent-to-Agent) JSON-RPC endpoint at `/a2a/v1`, and mirrors both discovery cards under `/.well-known/`. Has its own `package.json`, `wrangler.toml`, build script. Reads from the same `src/content/spec/` source of truth at build time. Logs each call to the `MCP_LOG` Analytics Engine dataset (`sw_mcp_log`). | -| `public/search-overlay.js` | ⌘K overlay logic. CSP-safe (no inline JS). | -| `public/search-init.js` | `/search/` page Pagefind initialiser. CSP-safe. | -| `scripts/generate-assets.mjs` | Generates icons + OG image from inline SVGs via `sharp`. Wired through `prebuild`/`predev`. | -| `scripts/check-skill.mjs` | Guards the hand-maintained Agent Skill against drift: page count, category count + list, MCP protocol revision, and the sha256 `digest` in `agent-skills/index.json`. `npm run check:skill` verifies (CI + pre-commit); `npm run sign:skill` rewrites what it can. Must run after Prettier, never before — it asserts `public/.well-known/` stays in `.prettierignore` so a reformat can't invalidate the digest. | -| `scripts/sign-ard-catalog.mjs` | Signs `public/.well-known/ai-catalog.json`'s `host.trustManifest` with a detached ES256 JWS (offline; key in gitignored `.ard-signing-key.json`). `npm run sign:ard` re-signs, `npm run check:ard` verifies. Re-run after any edit to ai-catalog.json. | +| Path | Purpose | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/content/spec//.md` | Spec content. Edit here. | +| `src/content.config.ts` | Content collection schema. Edit if adding a field. | +| `src/content/considered/.md` | Hand-curated register of standards deliberately **not** specced, with the reason and what would reverse it. Rendered at `/considered/`. Not derived. | +| `src/lib/site.ts` | Site metadata + the canonical category list. | +| `src/layouts/BaseLayout.astro` | HTML shell, head, dialog for ⌘K search, Plausible (PROD only). | +| `src/layouts/SpecLayout.astro` | Spec page wrapper; emits TechArticle + BreadcrumbList JSON-LD; advertises Markdown alt via `markdownUrl`. | +| `src/components/HeadMeta.astro` | `` metadata, canonical, OG, Twitter, JSON-LD, RSS / sitemap / markdown alternates. | +| `src/components/SiteHeader.astro` | Header nav. Contains the ⌘K trigger. | +| `src/components/SiteFooter.astro` | Footer. Privacy / search links live here. | +| `src/pages/spec/[category]/[slug].astro` | The dynamic HTML route. | +| `src/pages/spec/[category]/[slug].md.ts` | The dynamic Markdown route. | +| `src/pages/llms.txt.ts`, `src/pages/llms-full.txt.ts`, `src/pages/rss.xml.ts` | Derived endpoints. | +| `src/lib/okf.ts`, `src/pages/okf/` | OKF bundle generator + the `.md` endpoints that emit the `/okf/` tree (concepts, per-directory `index.md`, `references/`, `log.md`, root `index.md`). Derived from the `spec` + `changelog` collections. | +| `astro-okf-tarball.mjs` (root) | Astro integration; in `astro:build:done` it walks `dist/okf/`, writes a reproducible POSIX ustar archive by hand (no tar dependency), gzips it to `dist/okf.tar.gz`. | +| `functions/_middleware.ts` | Cloudflare Pages middleware. Does `Accept: text/markdown` content negotiation on canonical spec URLs, on `/` (→ `/llms.txt`), and on `/checklist/` (→ `/checklist.md`), and calls `logBot()` so crawler hits land in the `AGENT_LOG` Analytics Engine dataset. | +| `functions/_shared/bot-detect.ts` | UA / `signature-agent` / `cf-verified` / `Accept: text/markdown` detection plus `writeDataPoint()` to the `AGENT_LOG` Analytics Engine binding. Never throws. | +| `functions/reports.ts` | `/reports` collector for the W3C Reporting API. Browsers POST CSP/COOP/COEP violation, deprecation, intervention, and crash reports here (named by the `Reporting-Endpoints` header in `_headers`); writes one aggregate row per report to the `REPORT_LOG` Analytics Engine dataset (`sw_report_log`). Deprecation/intervention reports triggered by third-party (browser-extension) scripts are dropped — only same-origin `sourceFile`s are recorded. POST-only, never throws, returns 204; stores no IP, no cookies, no URL query strings. | +| `functions/admin/stats.ts` | `/admin/stats` dashboard. Queries `sw_agent_log` (crawlers), `sw_mcp_log` (MCP/A2A), and `sw_report_log` (browser policy reports) via the Cloudflare Analytics Engine SQL API using `CF_ACCOUNT_ID` + `CF_ANALYTICS_TOKEN` Pages secrets. **Behind Cloudflare Access** — the function assumes the caller is already authenticated. | +| `public/admin-stats.js` | Tab + filter JS for `/admin/stats`. Extracted to a file because the CSP forbids inline scripts. | +| `public/_routes.json` | Cloudflare Pages routing manifest — excludes static assets from the Functions worker so bot logging only runs on HTML and well-known paths. | +| `wrangler.toml` (root) | Pages bindings — the `AGENT_LOG` (`sw_agent_log`) and `REPORT_LOG` (`sw_report_log`) Analytics Engine datasets. Pages itself is deployed via the Pages dashboard's Git integration. | +| `public/_headers` | Cloudflare response headers — strict CSP, HSTS, Permissions-Policy, Vary on .md, content types for well-known files, the discovery `Link` header. | +| `public/.well-known/` | Static well-known URIs (security.txt, change-password, api-catalog, mcp/server-card.json, agent-card.json for A2A discovery, agent-skills/index.json + agent-skills//SKILL.md per the Agent Skills Discovery RFC v0.2.0 — if you edit a SKILL.md, recompute its sha256 and update the `digest` in index.json; ai-catalog.json is the ARD AI Catalog, published unsigned — see [agentic-resource-discovery](src/content/spec/agent-readiness/agentic-resource-discovery.md) for why a same-origin trust-manifest signature proves nothing). | +| `mcp/` | Cloudflare Worker exposing the spec at `mcp.specification.website`. Serves the MCP transport at `/mcp`, an A2A (Agent-to-Agent) JSON-RPC endpoint at `/a2a/v1`, and mirrors both discovery cards under `/.well-known/`. Has its own `package.json`, `wrangler.toml`, build script. Reads from the same `src/content/spec/` source of truth at build time. Logs each call to the `MCP_LOG` Analytics Engine dataset (`sw_mcp_log`). | +| `public/search-overlay.js` | ⌘K overlay logic. CSP-safe (no inline JS). | +| `public/search-init.js` | `/search/` page Pagefind initialiser. CSP-safe. | +| `scripts/generate-assets.mjs` | Generates icons + OG image from inline SVGs via `sharp`. Wired through `prebuild`/`predev`. | +| `scripts/check-skill.mjs` | Guards the hand-maintained Agent Skill against drift: page count, category count + list, MCP protocol revision, and the sha256 `digest` in `agent-skills/index.json`. `npm run check:skill` verifies (CI + pre-commit); `npm run sign:skill` rewrites what it can. Must run after Prettier, never before — it asserts `public/.well-known/` stays in `.prettierignore` so a reformat can't invalidate the digest. | ## Commands diff --git a/docs/okf-recon.md b/docs/okf-recon.md index 37a22c72..8d5772e2 100644 --- a/docs/okf-recon.md +++ b/docs/okf-recon.md @@ -3,6 +3,11 @@ Recon for the "publish the spec as an OKF bundle, fix discovery via ARD" plan. No source files were changed to produce this. Read this before Phase 1. +> **Point-in-time document.** Its ARD signing observations no longer describe the +> site: the `trustManifest` signature, `scripts/sign-ard-catalog.mjs`, and +> `jwks.json` were removed on 23 July 2026 because the verifying key shared an +> origin with the catalogue it signed. See `ops/notes/ard-adoption.md`. + ## State reconciliation (what the plan assumed vs. what is already shipped) | Plan phase | Status today | Notes | diff --git a/ops/notes/ard-adoption.md b/ops/notes/ard-adoption.md index c13bd348..458d9267 100644 --- a/ops/notes/ard-adoption.md +++ b/ops/notes/ard-adoption.md @@ -5,6 +5,16 @@ reference implementation of [Agentic Resource Discovery](https://agenticresource (ARD) — the Linux Foundation / Google draft announced June 2026. Not part of the deployed site; this file is scratch for the write-up. +> **Superseded in part, 23 July 2026.** The signing described in §9 was removed. +> The verifying JWK Set lived at `/.well-known/jwks.json`, on the same origin as +> the catalogue it vouched for — so anyone able to rewrite the catalogue could +> swap the key too, and the signature could not detect the compromise it existed +> for. It also covered `host.trustManifest` only, leaving every `entries` URL +> (the fields an attacker would actually target) unsigned. The catalogue is now +> served as plain JSON. The reasoning is on the +> [spec page](../../src/content/spec/agent-readiness/agentic-resource-discovery.md); +> it makes a better post than the signing walkthrough did. + ## The one-sentence pitch ARD is a discovery layer (not a runtime): a domain publishes an **AI Catalog** at diff --git a/package.json b/package.json index 1fca4874..a7afce0b 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,6 @@ "format": "prettier --write .", "format:check": "prettier --check .", "assets": "node scripts/generate-assets.mjs", - "sign:ard": "node scripts/sign-ard-catalog.mjs", - "check:ard": "node scripts/sign-ard-catalog.mjs --check", "sign:skill": "node scripts/check-skill.mjs", "check:skill": "node scripts/check-skill.mjs --check" }, diff --git a/public/.well-known/ai-catalog.json b/public/.well-known/ai-catalog.json index 8d5230f3..d51ef70b 100644 --- a/public/.well-known/ai-catalog.json +++ b/public/.well-known/ai-catalog.json @@ -4,13 +4,7 @@ "displayName": "The Website Specification", "identifier": "specification.website", "documentationUrl": "https://specification.website/spec/agent-readiness/agentic-resource-discovery/", - "logoUrl": "https://specification.website/icon-512.png", - "trustManifest": { - "identity": "https://specification.website/.well-known/jwks.json", - "identityType": "https", - "privacyPolicyUrl": "https://specification.website/privacy/", - "signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6Im5mYy14WnJwWVJEam9PalM2UVVaT2E3QVk5cTh1angyU2VaVk43MVBmNkkifQ..H5L3qJ-zcov8WtTxR_tc__y5a0VLkA7y4Agr0Auln5A5JxiqrcHXLEfByFOPR4zqFfWkMLLX19O3TFe6jJLDEA" - } + "logoUrl": "https://specification.website/icon-512.png" }, "entries": [ { diff --git a/public/.well-known/api-catalog b/public/.well-known/api-catalog index 185096e3..8d37a291 100644 --- a/public/.well-known/api-catalog +++ b/public/.well-known/api-catalog @@ -60,11 +60,6 @@ "href": "https://specification.website/.well-known/ai-catalog.json", "type": "application/ai-catalog+json", "title": "Agentic Resource Discovery (ARD) catalog" - }, - { - "href": "https://specification.website/.well-known/jwks.json", - "type": "application/jwk-set+json", - "title": "JWK Set — verifies the ARD catalog signature" } ], "license": [ diff --git a/public/_headers b/public/_headers index 686bdb33..7abdda2b 100644 --- a/public/_headers +++ b/public/_headers @@ -167,12 +167,6 @@ Cache-Control: public, max-age=3600 Access-Control-Allow-Origin: * -# JWK Set — public key for verifying the AI Catalog trustManifest signature -/.well-known/jwks.json - Content-Type: application/jwk-set+json; charset=utf-8 - Cache-Control: public, max-age=3600 - Access-Control-Allow-Origin: * - /.well-known/agent-skills/*/SKILL.md Content-Type: text/markdown; charset=utf-8 Cache-Control: public, max-age=3600 diff --git a/src/content/changelog/2026-07-23-ard-catalog-unsigned.md b/src/content/changelog/2026-07-23-ard-catalog-unsigned.md new file mode 100644 index 00000000..cd8fe05a --- /dev/null +++ b/src/content/changelog/2026-07-23-ard-catalog-unsigned.md @@ -0,0 +1,8 @@ +--- +title: "ARD catalogues: sign only when the key is anchored elsewhere" +date: "2026-07-23" +type: changed +relatedSlugs: [agentic-resource-discovery] +--- + +[Agentic Resource Discovery](/spec/agent-readiness/agentic-resource-discovery/) now explains when a `trustManifest` signature is worth anything and when it is theatre: a detached JWS whose verifying key sits on the same origin as the catalogue folds to the very compromise it is meant to detect, and it leaves `entries` — the endpoint URLs an attacker would actually rewrite — unsigned. This site removed its own ES256 signature accordingly and serves the catalogue as plain JSON. diff --git a/src/content/spec/agent-readiness/agentic-resource-discovery.md b/src/content/spec/agent-readiness/agentic-resource-discovery.md index 03c8105d..1d941b66 100644 --- a/src/content/spec/agent-readiness/agentic-resource-discovery.md +++ b/src/content/spec/agent-readiness/agentic-resource-discovery.md @@ -7,7 +7,7 @@ status: optional order: 86 appliesTo: [all] relatedSlugs: [dns-aid, mcp-and-tool-discovery, a2a-agent-cards, agent-skills-discovery, okf-bundle, link-headers, well-known-overview] -updated: "2026-06-19T12:00:00.000Z" +updated: "2026-07-23T00:00:00.000Z" sources: - title: "Agentic Resource Discovery (ARD) specification" url: "https://agenticresourcediscovery.org/" @@ -43,19 +43,28 @@ Beyond the well-known file itself, three mechanisms point agents at it: a ` Date: Fri, 31 Jul 2026 11:37:17 +0200 Subject: [PATCH 2/3] change(ard): remove the signing script and JWK Set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the removal: public/.well-known/jwks.json and scripts/sign-ard-catalog.mjs. Nothing references either — the _headers block, the api-catalog entry, the npm scripts and the CI step went in the previous commit. Co-Authored-By: Claude Opus 5 (1M context) --- public/.well-known/jwks.json | 13 --- scripts/sign-ard-catalog.mjs | 202 ----------------------------------- 2 files changed, 215 deletions(-) delete mode 100644 public/.well-known/jwks.json delete mode 100644 scripts/sign-ard-catalog.mjs diff --git a/public/.well-known/jwks.json b/public/.well-known/jwks.json deleted file mode 100644 index c0aea57c..00000000 --- a/public/.well-known/jwks.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "keys": [ - { - "kty": "EC", - "crv": "P-256", - "x": "7o27fy-nFelJ4uOBhINHiAx6otDovKy6fzNZEUYosiM", - "y": "IonkZ70Cq8_vpr0dOAY3vY1wZK3Wmyg_12UaMdCmCUU", - "use": "sig", - "alg": "ES256", - "kid": "nfc-xZrpYRDjoOjS6QUZOa7AY9q8ujx2SeZVN71Pf6I" - } - ] -} diff --git a/scripts/sign-ard-catalog.mjs b/scripts/sign-ard-catalog.mjs deleted file mode 100644 index 447b587b..00000000 --- a/scripts/sign-ard-catalog.mjs +++ /dev/null @@ -1,202 +0,0 @@ -// Sign the ARD AI Catalog's host trustManifest with a detached ES256 JWS. -// -// Offline signing: the private key lives in .ard-signing-key.json (gitignored), -// never in CI or Cloudflare. We commit the signed ai-catalog.json plus the -// public JWKS at public/.well-known/jwks.json. -// -// node scripts/sign-ard-catalog.mjs --init # one-time: generate the keypair -// node scripts/sign-ard-catalog.mjs # (re)sign the catalog -// node scripts/sign-ard-catalog.mjs --check # verify the committed signature -// -// Per the AI Catalog spec the signature covers the trustManifest only: -// canonicalise it with JCS (RFC 8785) after removing `signature`, then sign the -// canonical bytes as a detached JWS (RFC 7515) with ES256 (P-256). Verifiers -// resolve the key from the HTTPS `identity` URL (the JWKS) by the JWS `kid`. - -import { readFile, writeFile, access } from "node:fs/promises"; -import { webcrypto as crypto } from "node:crypto"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const CATALOG = join(root, "public/.well-known/ai-catalog.json"); -const JWKS = join(root, "public/.well-known/jwks.json"); -const KEYFILE = join(root, ".ard-signing-key.json"); - -const enc = new TextEncoder(); -const b64url = (buf) => Buffer.from(buf).toString("base64url"); -const EC = { name: "ECDSA", namedCurve: "P-256" }; -const SIGN = { name: "ECDSA", hash: "SHA-256" }; - -// RFC 8785 JSON Canonicalisation, restricted to the value types the trust -// manifest uses (string, boolean, null, array, object). Numbers would need -// the spec's float serialisation; we deliberately reject them so a future -// numeric field can't silently produce a non-canonical signature. -function jcs(value) { - if ( - value === null || - typeof value === "string" || - typeof value === "boolean" - ) { - return JSON.stringify(value); - } - if (typeof value === "number") { - throw new Error( - "jcs: numeric values are not supported in the trust manifest", - ); - } - if (Array.isArray(value)) { - return "[" + value.map(jcs).join(",") + "]"; - } - // Object keys sorted by UTF-16 code unit — JS default string sort. - const keys = Object.keys(value).sort(); - return ( - "{" + - keys.map((k) => JSON.stringify(k) + ":" + jcs(value[k])).join(",") + - "}" - ); -} - -// RFC 7638 JWK thumbprint for an EC key. -async function thumbprint(jwk) { - const ordered = { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }; - const hash = await crypto.subtle.digest("SHA-256", enc.encode(jcs(ordered))); - return b64url(hash); -} - -async function signDetached(pkcs8b64, kid, payloadBytes) { - const key = await crypto.subtle.importKey( - "pkcs8", - Buffer.from(pkcs8b64, "base64url"), - EC, - false, - ["sign"], - ); - const header = b64url(enc.encode(JSON.stringify({ alg: "ES256", kid }))); - const signingInput = header + "." + b64url(payloadBytes); - const sig = await crypto.subtle.sign(SIGN, key, enc.encode(signingInput)); - // Detached JWS: payload omitted from the compact serialisation. - return header + ".." + b64url(sig); -} - -async function verifyDetached(publicJwk, detached, payloadBytes) { - const [header, , sig] = detached.split("."); - const key = await crypto.subtle.importKey("jwk", publicJwk, EC, false, [ - "verify", - ]); - const signingInput = header + "." + b64url(payloadBytes); - return crypto.subtle.verify( - SIGN, - key, - Buffer.from(sig, "base64url"), - enc.encode(signingInput), - ); -} - -async function fileExists(p) { - try { - await access(p); - return true; - } catch { - return false; - } -} - -async function readJson(p) { - return JSON.parse(await readFile(p, "utf8")); -} - -async function writeJson(p, obj) { - await writeFile(p, JSON.stringify(obj, null, 2) + "\n"); -} - -async function initKey() { - if (await fileExists(KEYFILE)) { - throw new Error( - `${KEYFILE} already exists — refusing to overwrite an existing key`, - ); - } - const kp = await crypto.subtle.generateKey(EC, true, ["sign", "verify"]); - const pkcs8 = b64url(await crypto.subtle.exportKey("pkcs8", kp.privateKey)); - const pub = await crypto.subtle.exportKey("jwk", kp.publicKey); - const kid = await thumbprint(pub); - await writeJson(KEYFILE, { kid, pkcs8 }); - const jwks = { - keys: [ - { - kty: pub.kty, - crv: pub.crv, - x: pub.x, - y: pub.y, - use: "sig", - alg: "ES256", - kid, - }, - ], - }; - await writeJson(JWKS, jwks); - console.log(` generated keypair (kid ${kid})`); - console.log( - ` wrote private key → ${KEYFILE} (gitignored — move to secure storage)`, - ); - console.log(` wrote public JWKS → public/.well-known/jwks.json`); - return { pkcs8, kid }; -} - -function payloadOf(catalog) { - const tm = catalog?.host?.trustManifest; - if (!tm) throw new Error("catalog has no host.trustManifest"); - // Sign over the trustManifest WITHOUT its signature field (JCS bytes). - const rest = { ...tm }; - delete rest.signature; - return enc.encode(jcs(rest)); -} - -async function sign() { - const key = await readJson(KEYFILE); - const catalog = await readJson(CATALOG); - const payload = payloadOf(catalog); - const detached = await signDetached(key.pkcs8, key.kid, payload); - catalog.host.trustManifest.signature = detached; - await writeJson(CATALOG, catalog); - - const jwks = await readJson(JWKS); - const pub = jwks.keys.find((k) => k.kid === key.kid); - const ok = await verifyDetached(pub, detached, payload); - if (!ok) throw new Error("self-verification failed after signing"); - console.log( - ` signed host.trustManifest (kid ${key.kid}) and verified against JWKS ✓`, - ); -} - -async function check() { - const catalog = await readJson(CATALOG); - const jwks = await readJson(JWKS); - const detached = catalog?.host?.trustManifest?.signature; - if (!detached) throw new Error("no signature on host.trustManifest"); - const kid = JSON.parse( - Buffer.from(detached.split(".")[0], "base64url").toString(), - ).kid; - const pub = jwks.keys.find((k) => k.kid === kid); - if (!pub) throw new Error(`JWKS has no key with kid ${kid}`); - const ok = await verifyDetached(pub, detached, payloadOf(catalog)); - if (!ok) throw new Error("signature does NOT verify — re-run the signer"); - console.log(` signature verifies against JWKS (kid ${kid}) ✓`); -} - -const mode = process.argv[2]; -if (mode === "--init") { - await initKey(); - await sign(); -} else if (mode === "--check") { - await check(); -} else { - if (!(await fileExists(KEYFILE))) { - console.error( - `No signing key at ${KEYFILE}. Run \`node scripts/sign-ard-catalog.mjs --init\` first,\n` + - `or restore the key from secure storage.`, - ); - process.exit(1); - } - await sign(); -} From 8b5a7f4cfa3d286d78ac1d053d4341f266c28e5c Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 31 Jul 2026 11:38:27 +0200 Subject: [PATCH 3/3] change(ard): date the removal 31 July, the day it ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changelog entry, the spec page's `updated`, and the superseded banners all read 23 July — when the change was written, not when it lands. Co-Authored-By: Claude Opus 5 (1M context) --- docs/okf-recon.md | 2 +- ops/notes/ard-adoption.md | 2 +- ...d-catalog-unsigned.md => 2026-07-31-ard-catalog-unsigned.md} | 2 +- src/content/spec/agent-readiness/agentic-resource-discovery.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename src/content/changelog/{2026-07-23-ard-catalog-unsigned.md => 2026-07-31-ard-catalog-unsigned.md} (97%) diff --git a/docs/okf-recon.md b/docs/okf-recon.md index 8d5772e2..1cfbe647 100644 --- a/docs/okf-recon.md +++ b/docs/okf-recon.md @@ -5,7 +5,7 @@ No source files were changed to produce this. Read this before Phase 1. > **Point-in-time document.** Its ARD signing observations no longer describe the > site: the `trustManifest` signature, `scripts/sign-ard-catalog.mjs`, and -> `jwks.json` were removed on 23 July 2026 because the verifying key shared an +> `jwks.json` were removed on 31 July 2026 because the verifying key shared an > origin with the catalogue it signed. See `ops/notes/ard-adoption.md`. ## State reconciliation (what the plan assumed vs. what is already shipped) diff --git a/ops/notes/ard-adoption.md b/ops/notes/ard-adoption.md index 458d9267..021c63d8 100644 --- a/ops/notes/ard-adoption.md +++ b/ops/notes/ard-adoption.md @@ -5,7 +5,7 @@ reference implementation of [Agentic Resource Discovery](https://agenticresource (ARD) — the Linux Foundation / Google draft announced June 2026. Not part of the deployed site; this file is scratch for the write-up. -> **Superseded in part, 23 July 2026.** The signing described in §9 was removed. +> **Superseded in part, 31 July 2026.** The signing described in §9 was removed. > The verifying JWK Set lived at `/.well-known/jwks.json`, on the same origin as > the catalogue it vouched for — so anyone able to rewrite the catalogue could > swap the key too, and the signature could not detect the compromise it existed diff --git a/src/content/changelog/2026-07-23-ard-catalog-unsigned.md b/src/content/changelog/2026-07-31-ard-catalog-unsigned.md similarity index 97% rename from src/content/changelog/2026-07-23-ard-catalog-unsigned.md rename to src/content/changelog/2026-07-31-ard-catalog-unsigned.md index cd8fe05a..5ebb5734 100644 --- a/src/content/changelog/2026-07-23-ard-catalog-unsigned.md +++ b/src/content/changelog/2026-07-31-ard-catalog-unsigned.md @@ -1,6 +1,6 @@ --- title: "ARD catalogues: sign only when the key is anchored elsewhere" -date: "2026-07-23" +date: "2026-07-31" type: changed relatedSlugs: [agentic-resource-discovery] --- diff --git a/src/content/spec/agent-readiness/agentic-resource-discovery.md b/src/content/spec/agent-readiness/agentic-resource-discovery.md index 1d941b66..54b332cd 100644 --- a/src/content/spec/agent-readiness/agentic-resource-discovery.md +++ b/src/content/spec/agent-readiness/agentic-resource-discovery.md @@ -7,7 +7,7 @@ status: optional order: 86 appliesTo: [all] relatedSlugs: [dns-aid, mcp-and-tool-discovery, a2a-agent-cards, agent-skills-discovery, okf-bundle, link-headers, well-known-overview] -updated: "2026-07-23T00:00:00.000Z" +updated: "2026-07-31T00:00:00.000Z" sources: - title: "Agentic Resource Discovery (ARD) specification" url: "https://agenticresourcediscovery.org/"