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 df7cba88..00f16d2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,36 +69,35 @@ 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/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/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 — plain JSON, no signature, edit it freely). | +| `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 5ff6d3fc..afe04761 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/.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/public/_headers b/public/_headers index fcd33625..50d230a7 100644 --- a/public/_headers +++ b/public/_headers @@ -152,12 +152,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/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(); -} 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 `