From 04250e3fad2606a1d03f3abfbf0a7f91c724fbe7 Mon Sep 17 00:00:00 2001 From: dickhardt Date: Tue, 11 Aug 2026 22:10:47 +0100 Subject: [PATCH 1/2] AAuth -11: five access modes, person token before resource token, Ed25519 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit access_mode (types.ts, store.ts, validate.ts): - `aauth-access-token` renamed `session-token` — the credential a resource issues for its own consumption, returned in `AAuth-Access`. - Added `person-token` and R3's `per-call`. The set is now the five values of the AAuth Access Mode Value Registry, which -11 established with a Specification Required policy. - Extracted DEFAULT_ACCESS_MODE ('agent-token', the spec default) so the two places that fall back to it cannot drift. Validation scope: validate.ts keeps rejecting an access_mode outside the list, deliberately. The field is an open registry, so this is the registry service choosing what its directory will list — an editorial call, on the registry-curation side. It does not reach agent behaviour: nothing on the agent-provider (ap.ts), agent-token (agent-token.ts), or web-agent (client) paths reads access_mode, and the UI renders an unknown value verbatim rather than treating it as an error. Documented at each site. Person token before resource token (login.ts) — the substantive protocol fix. -11 §Resource Access: "A resource MUST have verified a person token before it issues a resource token, and MUST challenge with `requirement=person-token` when it has not." Only a PS can redeem a resource token, so one issued to an agent that cannot name a person is unredeemable. The login flow was minting resource tokens straight off an agent token, which is both wrong and the reason it had nothing to put in `ps`/`sub`/`person_token_jti`. The flow is now three rungs, at /auth/identity and POST /resources alike: agent token → 401 `AAuth-Requirement: requirement=person-token` person token → verified, then 401 `requirement=auth-token` + resource token auth token → verified; identity read, session set verifyPersonToken() implements §Person Token Verification: `typ` `aa-person+jwt`, `dwk` `aauth-person.json`, JWKS at `{iss}/.well-known/{dwk}` with the byte-equal issuer check, `aud` = our ORIGIN, `cnf.jwk` = the key that signed the request, plus exp/iat, sub, jti, and the MUST NOT on scope/account. Failures are `400 invalid_person_token`. Payload checks run before the signature step so a structurally invalid token never causes an outbound fetch to a host it names. The resource token now carries `ps`, `sub` and `person_token_jti` copied from that token, and still no `agent` claim. A person token alone does not attribute a submission: it is a directed `(iss, sub)` with no scope, so it cannot release the verified email and name an entry is credited with. It earns the resource token; the auth token carries the claims. This does not change the registry's own access_mode — listing still requires only an agent token. Signature algorithms (crypto.ts and its three callers): -11 requires a fully-specified `alg` everywhere and forbids the polymorphic `EdDSA`. - New SIGNING_ALG = 'Ed25519', used by the resource token (login.ts), the agent token (ap.ts), and the session cookie (session.ts). The last is this service's own format, not an AAuth token type, but it shares the key and the verifier, so it moves too. - verifyJWT no longer accepts `EdDSA`: the entry is gone from JWT_ALG_PARAMS, so such a JWT fails with `unsupported alg: EdDSA`. A JWKS key whose own algorithm contradicts the header is no longer a candidate. - The two `alg` strips before crypto.subtle.importKey stay exactly as they were. workerd rejects an OKP JWK whose alg is "Ed25519", and the algorithm is passed explicitly via the import parameters. Emit Ed25519, strip before import — opposite ends of the same pipe. - Flag-day blocker recorded in the code: HelloCoop/Wallet/svr/issuer/sign.js:32 still signs every AAuth token with `EdDSA`, and must ship `Ed25519` in the same window or auth tokens stop verifying here. Client (client/registry.js, rebuilt public/registry.js): the browser web-agent climbs the same ladder — person token from the PS's `person_token_endpoint`, then the resource token, then the auth token from `auth_token_endpoint`. Either PS step can defer with a 202, so the saved pending state records which rung deferred and resumePending() picks up from there. Display (discoverability.ts, client, README): all five values described, with the note that an agent meeting an unrecognized value proceeds as with no declaration, and that a resource may state a mode per operation via an R3 operation access annotation in its own vocabulary. scripts/test.sh: assert the published JWK carries alg "Ed25519". CLAUDE.md: the login ladder, and why signed requests fail under plain `wrangler dev` (the custom_domain route rewrites the authority the Worker sees, and httpsig reports the mismatch with no error string). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FmiCqDjRUSx6zb1N4gZPXE --- CLAUDE.md | 23 +++- README.md | 22 +++- client/registry.js | 161 ++++++++++++++++++++-------- public/registry.js | 155 +++++++++++++++++---------- scripts/test.sh | 4 + src/ap.ts | 13 ++- src/crypto.ts | 57 +++++++--- src/discoverability.ts | 26 +++++ src/login.ts | 236 ++++++++++++++++++++++++++++++----------- src/session.ts | 8 +- src/store.ts | 4 +- src/types.ts | 27 ++++- src/validate.ts | 25 ++++- 13 files changed, 573 insertions(+), 188 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cdb2570..5a7e937 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,16 @@ registry-aauth-dev → Deployments). - `npm run typecheck` — `tsc --noEmit`. - `bash scripts/test.sh [base_url]` — curl smoke tests. +**Signed requests fail under plain `wrangler dev`, and it is not your +signature.** The `[[routes]] custom_domain` entry makes the dev server hand +the Worker a URL whose host is `registry.aauth.dev`, so `new URL(c.req.url) +.host` — what every handler passes to httpsig as `authority` — does not match +the `@authority` the client signed (`localhost:PORT`). httpsig reports +`verified: false` with **no** `error` string, which reads like a bad key. To +test signed flows locally, run with a config copy that drops the `[[routes]]` +block, and set `ORIGIN` to `http://localhost:PORT` so `aud` checks line up +(`.dev.vars` overrides `[vars]`, so set it there). + ## Architecture quick ref - Cloudflare Worker (`src/index.ts`, Hono). Plays the AAuth **resource** @@ -61,7 +71,7 @@ registry-aauth-dev → Deployments). | `GET /.well-known/jwks.json` | none | Public key (shared by both roles) | | `GET /robots.txt` · `/sitemap.xml` · `/llms.txt` | none | Discoverability — keep in sync with routes | | `POST /bootstrap` | sig=hwk | Mint a browser web-agent token (AP role); `ps` defaults to Hellō | -| `GET /auth/identity` | agent/auth token | Login: agent token → resource_token challenge; auth token → set session | +| `GET /auth/identity` | agent/person/auth token | Login ladder: agent token → 401 `requirement=person-token`; person token → 401 `requirement=auth-token` + resource_token; auth token → set session | | `GET /auth/session` | session cookie | Current human session or `{logged_in:false}` | | `POST /auth/logout` | — | Clear session cookie | | `GET /resources` | agent token | List (R2 index, ETag) | @@ -74,6 +84,17 @@ registry-aauth-dev → Deployments). (signed session cookie). Server side done; the browser UI (`public/`, ported from playground's client) is still to come. +**Person token first.** AAuth -11: a resource MUST have verified a person +token before it issues a resource token, and MUST challenge with +`requirement=person-token` when it has not — only a PS can redeem a resource +token, so one that names no person is unredeemable. `src/login.ts` verifies +the person token (`typ: aa-person+jwt`, `dwk: aauth-person.json`, JWKS at +`{iss}/.well-known/{dwk}`, `aud` = our ORIGIN, `cnf.jwk` = the request signing +key) and copies its `iss`/`sub`/`jti` into the resource token's +`ps`/`sub`/`person_token_jti`. A bad one is `400 invalid_person_token`. This +does not change the registry's own `access_mode`: listing still needs only an +agent token. + ## Cloudflare setup (one-time, outside this repo) - KV namespace → put its id in `wrangler.toml` (`REGISTRY_KV`). diff --git a/README.md b/README.md index cdc9c9b..be195e5 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,28 @@ discovery convenience, never a gatekeeper. Body: `{ "issuer": "https://notes.aauth.dev" }`. The registry fetches **only** `https://{host}/.well-known/aauth-resource.json` (no redirects, timeout, size cap), and requires `issuer === https://{host}` (anti-spoof — proves control of -the host), a present `description`, and a valid `access_mode`. It caches the -resource's `name`, `description`, `access_mode`, `logo_uri`, and the submitting -agent (`submitted_by`). +the host), a present `description`, and an `access_mode` the registry lists +(or none — the default is `agent-token`). It caches the resource's `name`, +`description`, `access_mode`, `documentation_uri`, and the submitting agent +(`submitted_by`). Responses: `201 added`, `200 already_present`, `422 metadata_invalid`. +### `access_mode` + +The registry lists the five registered values: `agent-token`, `person-token`, +`session-token`, `auth-token`, and `per-call` (defined by AAuth R3). The field +is an IANA registry with a Specification Required policy, so more values can +appear; the registry declining to list an unregistered one is an editorial +choice about this directory, not protocol behaviour. **Agents must not copy +it:** an agent meeting an `access_mode` it does not recognize proceeds as it +would with no declaration — it calls the resource and reads the +`AAuth-Requirement` it gets back. + +The value here describes the resource as a whole. A resource may also state a +mode per operation, as an R3 operation access annotation on the operation in +its own vocabulary; read the resource's vocabulary for that detail. + ## Develop ```bash diff --git a/client/registry.js b/client/registry.js index 6a7713d..9c3ec84 100644 --- a/client/registry.js +++ b/client/registry.js @@ -2,10 +2,17 @@ // // The page is an AAuth web-agent. It bootstraps an agent token (signed // sig=hwk with a key held in IndexedDB), lists resources (sig=jwt with the -// agent token), and — to add a resource — runs the auth-token flow against -// the person's Person Server (Hellō): the registry challenges, the human -// approves at the PS (the interaction), and the resulting auth_token proves -// a verified identity. Bundled to public/registry.js by `npm run build:client`. +// agent token), and — to add a resource or log in — climbs the AAuth ladder +// against the person's Person Server (Hellō): +// +// agent token → registry answers 401 requirement=person-token +// person token → from the PS's person_token_endpoint; registry verifies it +// and answers 401 requirement=auth-token + resource token +// auth token → from the PS's auth_token_endpoint, given that resource +// token; proves a verified identity to the registry +// +// The person approves at the PS (the interaction), which either step may +// defer to with a 202. Bundled to public/registry.js by `npm run build:client`. import { fetch as sigFetch } from '@hellocoop/httpsig' @@ -160,59 +167,92 @@ function parseRequirement(h) { return out } -// Begin the auth-token flow. Returns { authToken } if consent was cached -// (the PS answered 200). If consent is needed (202), saves `pending` plus -// the poll URL and redirects this page to Hellō — returns { redirecting:true } -// and the caller should stop (the page is navigating away). -async function startAuthFlow(pending) { - let agentToken = await ensureAgentToken() +// A 202 from the PS means the person has to approve at Hellō first. Save the +// pending action plus the poll URL and redirect this page there; on return +// resumePending() polls for the token and picks the flow back up. `stage` +// records which token we were waiting for, since either step can defer. +async function deferToPersonServer(psRes, psMeta, pending, stage) { + const body = await psRes.json().catch(() => ({})) + const req = parseRequirement(psRes.headers.get('aauth-requirement')) + const interactionUrl = req.url || body.url || psMeta.interaction_endpoint + const code = req.code || body.code + const pollUrl = new URL(psRes.headers.get('location') || body.location, PS_DEFAULT).toString() - // Get the resource_token challenge. If the agent token is stale/rejected - // (no challenge header on the 401), re-bootstrap once and retry. - const challenge = async () => { - const res = await signedFetch(`${ORIGIN}/auth/identity`, { jwt: agentToken }) - return res.status === 401 ? parseRequirement(res.headers.get('aauth-requirement'))['resource-token'] : null - } - let resourceToken = await challenge() - if (!resourceToken) { - await bootstrap() - agentToken = getAgentToken() - resourceToken = await challenge() + savePending({ ...pending, stage, pollUrl }) + window.location.href = `${interactionUrl}?code=${encodeURIComponent(code)}&callback=${encodeURIComponent(ORIGIN + '/')}` + return { redirecting: true } +} + +// Step 1 of the climb: a person token for this resource, from the PS's +// person_token_endpoint. It names the person to the registry and is what the +// registry must verify before it will issue a resource token at all. +async function obtainPersonToken(agentToken, psMeta, pending) { + if (!psMeta.person_token_endpoint) throw new Error('PS publishes no person_token_endpoint') + const res = await signedFetch(psMeta.person_token_endpoint, { + method: 'POST', + jwt: agentToken, + body: JSON.stringify({ resource: ORIGIN }), + }) + if (res.status === 200) { + const body = await res.json() + if (!body.person_token) throw new Error('PS returned no person_token') + return { personToken: body.person_token } } - if (!resourceToken) throw new Error('no resource_token in challenge') + if (res.status === 202) return deferToPersonServer(res, psMeta, pending, 'person') + throw new Error(`PS person token endpoint ${res.status}`) +} - const psMeta = await (await fetch(`${PS_DEFAULT}/.well-known/aauth-person.json`)).json() - const psRes = await signedFetch(psMeta.token_endpoint, { +// Step 2: present the person token to the registry and read the resource +// token out of its 401 auth-token challenge. +async function resourceTokenFor(personToken) { + const res = await signedFetch(`${ORIGIN}/auth/identity`, { jwt: personToken }) + if (res.status !== 401) throw new Error(`expected an auth-token challenge, got ${res.status}`) + const rt = parseRequirement(res.headers.get('aauth-requirement'))['resource-token'] + if (!rt) throw new Error('no resource_token in challenge') + return rt +} + +// Step 3: take the resource token to the PS for an auth token. +async function obtainAuthToken(agentToken, psMeta, resourceToken, pending) { + const res = await signedFetch(psMeta.auth_token_endpoint, { method: 'POST', jwt: agentToken, body: JSON.stringify({ resource_token: resourceToken, capabilities: ['interaction'], prompt: 'consent' }), }) - - if (psRes.status === 200) { - const body = await psRes.json() + if (res.status === 200) { + const body = await res.json() if (!body.auth_token) throw new Error('PS returned no auth_token') return { authToken: body.auth_token } } - if (psRes.status !== 202) throw new Error(`PS token endpoint ${psRes.status}`) + if (res.status === 202) return deferToPersonServer(res, psMeta, pending, 'auth') + throw new Error(`PS auth token endpoint ${res.status}`) +} - const body = await psRes.json().catch(() => ({})) - const req = parseRequirement(psRes.headers.get('aauth-requirement')) - const interactionUrl = req.url || body.url || psMeta.interaction_endpoint - const code = req.code || body.code - const pollUrl = new URL(psRes.headers.get('location') || body.location, PS_DEFAULT).toString() +// Walk the whole ladder: agent token → person token → resource token → auth +// token. Returns { authToken }, or { redirecting:true } if the PS deferred to +// the person at either step (the page is navigating away; stop). +async function startAuthFlow(pending) { + const agentToken = await ensureAgentToken() + const psMeta = await (await fetch(`${PS_DEFAULT}/.well-known/aauth-person.json`)).json() - savePending({ ...pending, pollUrl }) - // Redirect this page to Hellō; the PS sends us back to ORIGIN/ after approval. - window.location.href = `${interactionUrl}?code=${encodeURIComponent(code)}&callback=${encodeURIComponent(ORIGIN + '/')}` - return { redirecting: true } + const person = await obtainPersonToken(agentToken, psMeta, pending) + if (person.redirecting) return person + + return finishAfterPersonToken(person.personToken, agentToken, psMeta, pending) +} + +async function finishAfterPersonToken(personToken, agentToken, psMeta, pending) { + const resourceToken = await resourceTokenFor(personToken) + return obtainAuthToken(agentToken, psMeta, resourceToken, pending) } -async function pollForAuthToken(pollUrl, agentToken, maxCycles = 40) { +// Poll a PS deferred-response URL for whichever token we are waiting on. +async function pollForToken(pollUrl, agentToken, field, maxCycles = 40) { for (let i = 0; i < maxCycles; i++) { const res = await signedFetch(pollUrl, { jwt: agentToken, headers: { Prefer: 'wait=30' } }) if (res.status === 200) { const body = await res.json() - if (body.auth_token) return body.auth_token + if (body[field]) return body[field] } else if (res.status === 403 || res.status === 404 || res.status === 408) { throw new Error(`consent ${res.status}`) } @@ -237,7 +277,9 @@ async function completeWithAuthToken(authToken, pending) { return res.json() } -// On load, if we're returning from a Hellō redirect, finish the flow. +// On load, if we're returning from a Hellō redirect, finish the flow. Either +// rung can defer, so pick up from the one that did: a person token still has +// the resource-token and auth-token steps ahead of it. async function resumePending() { const pending = loadPending() if (!pending) return false @@ -247,8 +289,17 @@ async function resumePending() { try { const agentToken = getAgentToken() if (!agentToken) throw new Error('agent token missing after redirect') - const authToken = await pollForAuthToken(pending.pollUrl, agentToken) - await completeWithAuthToken(authToken, pending) + + if (pending.stage === 'person') { + const personToken = await pollForToken(pending.pollUrl, agentToken, 'person_token') + const psMeta = await (await fetch(`${PS_DEFAULT}/.well-known/aauth-person.json`)).json() + const r = await finishAfterPersonToken(personToken, agentToken, psMeta, pending) + if (r.redirecting) return true // deferred again at the auth-token step + await completeWithAuthToken(r.authToken, pending) + } else { + const authToken = await pollForToken(pending.pollUrl, agentToken, 'auth_token') + await completeWithAuthToken(authToken, pending) + } } catch (err) { console.error('resume failed', err) } @@ -290,6 +341,30 @@ const logout = () => fetch(`${ORIGIN}/auth/logout`, { method: 'POST' }).then(() const $ = (id) => document.getElementById(id) const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) +// What each access_mode means, for the badge tooltip. `access_mode` is an +// IANA registry (Specification Required), so this map is open-ended by +// design: an unknown value is shown verbatim with a neutral tooltip, never +// hidden or flagged as an error. This page is a web agent, and an agent that +// meets a value it does not recognize proceeds as it would with no +// declaration — calls the resource and reads the AAuth-Requirement it gets +// back. Nothing here branches on the value. +const ACCESS_MODE_TITLES = { + 'agent-token': 'Authorizes on the agent’s identity alone', + 'person-token': 'Authorizes on the person’s identity alone', + 'session-token': 'Runs its own authorization and issues a session token', + 'auth-token': 'Needs an auth token from your person server', + 'per-call': 'Authorizes each call individually, against that call’s parameters', +} + +const accessModeTitle = (mode) => + ACCESS_MODE_TITLES[mode] ?? + (mode + ? `Access mode “${mode}” — not one this page knows; agents call the resource and read its AAuth-Requirement` + : 'No access mode declared — defaults to agent-token') + +// The mode a resource declares covers the resource as a whole. A resource may +// also state a mode per operation, as an R3 operation access annotation in its +// own vocabulary; those are read from the resource, not from this registry. function renderResources(index) { const list = $('resources') const items = index.resources || [] @@ -303,7 +378,7 @@ function renderResources(index) {
${esc(r.name)} - ${esc(r.access_mode)} + ${esc(r.access_mode || 'agent-token')}

${esc(r.description)}

${esc(r.issuer)}
diff --git a/public/registry.js b/public/registry.js index acec111..1cc06b8 100644 --- a/public/registry.js +++ b/public/registry.js @@ -26,9 +26,9 @@ mod )); - // node_modules/@hellocoop/httpsig/dist/types.js + // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/types.js var require_types = __commonJS({ - "node_modules/@hellocoop/httpsig/dist/types.js"(exports) { + "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/types.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.DEFAULT_COMPONENTS_BODY = exports.DEFAULT_COMPONENTS_GET = exports.VALID_DERIVED_COMPONENTS = void 0; @@ -59,9 +59,9 @@ } }); - // node_modules/@hellocoop/httpsig/dist/errors.js + // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/errors.js var require_errors = __commonJS({ - "node_modules/@hellocoop/httpsig/dist/errors.js"(exports) { + "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/errors.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SignatureVerificationError = void 0; @@ -117,9 +117,9 @@ } }); - // node_modules/@hellocoop/httpsig/dist/utils/crypto.js + // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/crypto.js var require_crypto = __commonJS({ - "node_modules/@hellocoop/httpsig/dist/utils/crypto.js"(exports) { + "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/crypto.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.UNIMPLEMENTED_ALGORITHMS = exports.SYMMETRIC_ALGORITHMS = exports.POLYMORPHIC_ALGORITHMS = exports.SUPPORTED_ALGORITHMS = exports.FULLY_SPECIFIED_ALGORITHMS = void 0; @@ -248,15 +248,17 @@ function validateJwk(jwk) { determineAlgorithm(jwk); } + function withoutAlg(jwk) { + const { alg: _alg, ...rest } = jwk; + return rest; + } async function importPrivateKey(jwk) { const algorithm = determineAlgorithm(jwk); - return await crypto.subtle.importKey("jwk", jwk, algorithm, false, ["sign"]); + return await crypto.subtle.importKey("jwk", withoutAlg(jwk), algorithm, false, ["sign"]); } async function importPublicKey(jwk) { const algorithm = determineAlgorithm(jwk); - return await crypto.subtle.importKey("jwk", jwk, algorithm, false, [ - "verify" - ]); + return await crypto.subtle.importKey("jwk", withoutAlg(jwk), algorithm, false, ["verify"]); } function getPublicJwk(privateJwk) { const { d, p, q, dp, dq, qi, ...publicJwk2 } = privateJwk; @@ -288,9 +290,9 @@ } }); - // node_modules/@hellocoop/httpsig/dist/utils/base64.js + // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/base64.js var require_base64 = __commonJS({ - "node_modules/@hellocoop/httpsig/dist/utils/base64.js"(exports) { + "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/base64.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.base64urlEncode = base64urlEncode; @@ -348,9 +350,9 @@ } }); - // node_modules/@hellocoop/httpsig/dist/utils/signature.js + // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/signature.js var require_signature = __commonJS({ - "node_modules/@hellocoop/httpsig/dist/utils/signature.js"(exports) { + "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/signature.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.generateSignatureBase = generateSignatureBase; @@ -674,9 +676,9 @@ } }); - // node_modules/@hellocoop/httpsig/dist/fetch.js + // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/fetch.js var require_fetch = __commonJS({ - "node_modules/@hellocoop/httpsig/dist/fetch.js"(exports) { + "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/fetch.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.fetch = fetch2; @@ -832,9 +834,9 @@ } }); - // node_modules/@hellocoop/httpsig/dist/utils/thumbprint.js + // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/thumbprint.js var require_thumbprint = __commonJS({ - "node_modules/@hellocoop/httpsig/dist/utils/thumbprint.js"(exports) { + "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/thumbprint.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.calculateThumbprint = calculateThumbprint; @@ -886,9 +888,9 @@ } }); - // node_modules/@hellocoop/httpsig/dist/utils/cache.js + // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/cache.js var require_cache = __commonJS({ - "node_modules/@hellocoop/httpsig/dist/utils/cache.js"(exports) { + "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/cache.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.BoundedTtlCache = exports.DEFAULT_MAX_ENTRIES = void 0; @@ -951,9 +953,9 @@ } }); - // node_modules/@hellocoop/httpsig/dist/verify.js + // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/verify.js var require_verify = __commonJS({ - "node_modules/@hellocoop/httpsig/dist/verify.js"(exports) { + "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/verify.js"(exports) { "use strict"; var __createBinding = exports && exports.__createBinding || (Object.create ? (function(o, m, k, k2) { if (k2 === void 0) k2 = k; @@ -1371,9 +1373,9 @@ } }); - // node_modules/@hellocoop/httpsig/dist/helpers.js + // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/helpers.js var require_helpers = __commonJS({ - "node_modules/@hellocoop/httpsig/dist/helpers.js"(exports) { + "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/helpers.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.expressVerify = expressVerify; @@ -1429,9 +1431,9 @@ } }); - // node_modules/@hellocoop/httpsig/dist/index.js + // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/index.js var require_dist = __commonJS({ - "node_modules/@hellocoop/httpsig/dist/index.js"(exports) { + "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/index.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.DEFAULT_COMPONENTS_BODY = exports.DEFAULT_COMPONENTS_GET = exports.VALID_DERIVED_COMPONENTS = exports.calculateThumbprint = exports.SignatureVerificationError = exports.SUPPORTED_ALGORITHMS = exports.determineAlgorithm = exports.generateKeyPair = exports.parseAcceptSignatureAlg = exports.generateAcceptSignatureAlgHeader = exports.parseAcceptSignatureScheme = exports.generateAcceptSignatureSchemeHeader = exports.parseAcceptSignature = exports.generateAcceptSignatureHeader = exports.parseSignatureError = exports.generateSignatureErrorHeader = exports.nextJsPagesVerify = exports.nextJsVerify = exports.fastifyVerify = exports.expressVerify = exports.verify = void 0; @@ -1630,46 +1632,69 @@ }); return out; } - async function startAuthFlow(pending) { - let agentToken = await ensureAgentToken(); - const challenge = async () => { - const res = await signedFetch(`${ORIGIN}/auth/identity`, { jwt: agentToken }); - return res.status === 401 ? parseRequirement(res.headers.get("aauth-requirement"))["resource-token"] : null; - }; - let resourceToken = await challenge(); - if (!resourceToken) { - await bootstrap(); - agentToken = getAgentToken(); - resourceToken = await challenge(); - } - if (!resourceToken) throw new Error("no resource_token in challenge"); - const psMeta = await (await fetch(`${PS_DEFAULT}/.well-known/aauth-person.json`)).json(); - const psRes = await signedFetch(psMeta.token_endpoint, { - method: "POST", - jwt: agentToken, - body: JSON.stringify({ resource_token: resourceToken, capabilities: ["interaction"], prompt: "consent" }) - }); - if (psRes.status === 200) { - const body2 = await psRes.json(); - if (!body2.auth_token) throw new Error("PS returned no auth_token"); - return { authToken: body2.auth_token }; - } - if (psRes.status !== 202) throw new Error(`PS token endpoint ${psRes.status}`); + async function deferToPersonServer(psRes, psMeta, pending, stage) { const body = await psRes.json().catch(() => ({})); const req = parseRequirement(psRes.headers.get("aauth-requirement")); const interactionUrl = req.url || body.url || psMeta.interaction_endpoint; const code = req.code || body.code; const pollUrl = new URL(psRes.headers.get("location") || body.location, PS_DEFAULT).toString(); - savePending({ ...pending, pollUrl }); + savePending({ ...pending, stage, pollUrl }); window.location.href = `${interactionUrl}?code=${encodeURIComponent(code)}&callback=${encodeURIComponent(ORIGIN + "/")}`; return { redirecting: true }; } - async function pollForAuthToken(pollUrl, agentToken, maxCycles = 40) { + async function obtainPersonToken(agentToken, psMeta, pending) { + if (!psMeta.person_token_endpoint) throw new Error("PS publishes no person_token_endpoint"); + const res = await signedFetch(psMeta.person_token_endpoint, { + method: "POST", + jwt: agentToken, + body: JSON.stringify({ resource: ORIGIN }) + }); + if (res.status === 200) { + const body = await res.json(); + if (!body.person_token) throw new Error("PS returned no person_token"); + return { personToken: body.person_token }; + } + if (res.status === 202) return deferToPersonServer(res, psMeta, pending, "person"); + throw new Error(`PS person token endpoint ${res.status}`); + } + async function resourceTokenFor(personToken) { + const res = await signedFetch(`${ORIGIN}/auth/identity`, { jwt: personToken }); + if (res.status !== 401) throw new Error(`expected an auth-token challenge, got ${res.status}`); + const rt = parseRequirement(res.headers.get("aauth-requirement"))["resource-token"]; + if (!rt) throw new Error("no resource_token in challenge"); + return rt; + } + async function obtainAuthToken(agentToken, psMeta, resourceToken, pending) { + const res = await signedFetch(psMeta.auth_token_endpoint, { + method: "POST", + jwt: agentToken, + body: JSON.stringify({ resource_token: resourceToken, capabilities: ["interaction"], prompt: "consent" }) + }); + if (res.status === 200) { + const body = await res.json(); + if (!body.auth_token) throw new Error("PS returned no auth_token"); + return { authToken: body.auth_token }; + } + if (res.status === 202) return deferToPersonServer(res, psMeta, pending, "auth"); + throw new Error(`PS auth token endpoint ${res.status}`); + } + async function startAuthFlow(pending) { + const agentToken = await ensureAgentToken(); + const psMeta = await (await fetch(`${PS_DEFAULT}/.well-known/aauth-person.json`)).json(); + const person = await obtainPersonToken(agentToken, psMeta, pending); + if (person.redirecting) return person; + return finishAfterPersonToken(person.personToken, agentToken, psMeta, pending); + } + async function finishAfterPersonToken(personToken, agentToken, psMeta, pending) { + const resourceToken = await resourceTokenFor(personToken); + return obtainAuthToken(agentToken, psMeta, resourceToken, pending); + } + async function pollForToken(pollUrl, agentToken, field, maxCycles = 40) { for (let i = 0; i < maxCycles; i++) { const res = await signedFetch(pollUrl, { jwt: agentToken, headers: { Prefer: "wait=30" } }); if (res.status === 200) { const body = await res.json(); - if (body.auth_token) return body.auth_token; + if (body[field]) return body[field]; } else if (res.status === 403 || res.status === 404 || res.status === 408) { throw new Error(`consent ${res.status}`); } @@ -1699,8 +1724,16 @@ try { const agentToken = getAgentToken(); if (!agentToken) throw new Error("agent token missing after redirect"); - const authToken = await pollForAuthToken(pending.pollUrl, agentToken); - await completeWithAuthToken(authToken, pending); + if (pending.stage === "person") { + const personToken = await pollForToken(pending.pollUrl, agentToken, "person_token"); + const psMeta = await (await fetch(`${PS_DEFAULT}/.well-known/aauth-person.json`)).json(); + const r = await finishAfterPersonToken(personToken, agentToken, psMeta, pending); + if (r.redirecting) return true; + await completeWithAuthToken(r.authToken, pending); + } else { + const authToken = await pollForToken(pending.pollUrl, agentToken, "auth_token"); + await completeWithAuthToken(authToken, pending); + } } catch (err) { console.error("resume failed", err); } @@ -1731,6 +1764,14 @@ }); var $ = (id) => document.getElementById(id); var esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + var ACCESS_MODE_TITLES = { + "agent-token": "Authorizes on the agent\u2019s identity alone", + "person-token": "Authorizes on the person\u2019s identity alone", + "session-token": "Runs its own authorization and issues a session token", + "auth-token": "Needs an auth token from your person server", + "per-call": "Authorizes each call individually, against that call\u2019s parameters" + }; + var accessModeTitle = (mode) => ACCESS_MODE_TITLES[mode] ?? (mode ? `Access mode \u201C${mode}\u201D \u2014 not one this page knows; agents call the resource and read its AAuth-Requirement` : "No access mode declared \u2014 defaults to agent-token"); function renderResources(index) { const list = $("resources"); const items = index.resources || []; @@ -1743,7 +1784,7 @@
${esc(r.name)} - ${esc(r.access_mode)} + ${esc(r.access_mode || "agent-token")}

${esc(r.description)}

${esc(r.issuer)}
diff --git a/scripts/test.sh b/scripts/test.sh index 940fd3e..b6d6e10 100644 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -51,6 +51,10 @@ check "has at least one key" \ "$(echo "$JWKS" | jq -e '.keys | length > 0' 2>/dev/null || echo false)" check "key is OKP/Ed25519" \ "$(echo "$JWKS" | jq -e '.keys[0].kty == "OKP" and .keys[0].crv == "Ed25519"' 2>/dev/null || echo false)" +# AAuth §Signature Algorithms: every published JWK carries a fully-specified +# alg, and the polymorphic "EdDSA" MUST NOT be used. +check "key alg is fully-specified Ed25519 (not EdDSA)" \ + "$(echo "$JWKS" | jq -e '.keys[0].alg == "Ed25519"' 2>/dev/null || echo false)" check "no private key material" \ "$(echo "$JWKS" | jq -e '.keys[0].d == null' 2>/dev/null || echo false)" echo diff --git a/src/ap.ts b/src/ap.ts index a8dcdbf..a0739cc 100644 --- a/src/ap.ts +++ b/src/ap.ts @@ -6,7 +6,14 @@ import type { Context } from 'hono' import { verify as httpSigVerify } from '@hellocoop/httpsig' -import { getPublicJWK, importSigningKey, generateJTI, sanitizeCnfJwk, signJWT } from './crypto' +import { + getPublicJWK, + importSigningKey, + generateJTI, + sanitizeCnfJwk, + signJWT, + SIGNING_ALG, +} from './crypto' import type { Env } from './types' type HonoEnv = { Bindings: Env } @@ -91,7 +98,9 @@ export async function mintAgentToken( const publicJwk = await getPublicJWK(env.SIGNING_KEY) const now = Math.floor(Date.now() / 1000) - const header = { alg: 'EdDSA', typ: 'aa-agent+jwt', kid: publicJwk.kid } + // alg is fully specified (AAuth -11 §Agent Token Structure); cnf.jwk gets + // its own fully-specified alg from sanitizeCnfJwk. + const header = { alg: SIGNING_ALG, typ: 'aa-agent+jwt', kid: publicJwk.kid } const payload: Record = { iss: origin, dwk: 'aauth-agent.json', diff --git a/src/crypto.ts b/src/crypto.ts index 3c98e40..f5a8ba6 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -1,4 +1,15 @@ -// Ed25519 JWT signing & verification — shared with other AAuth servers +// Ed25519 JWT signing & verification — shared with other AAuth servers. +// +// AAuth -11 §Signature Algorithms: every `alg` — in a JWT header and in the +// `alg` member of every JWK — MUST be a fully-specified identifier. The +// polymorphic `EdDSA` MUST NOT be used; RFC 9864 replaced it with `Ed25519` +// (and `Ed448`). This module is where that is enforced, on both sides: +// SIGNING_ALG is what we put in JWT headers, fullySpecifiedAlg() stamps every +// JWK we publish or put in a cnf, and JWT_ALG_PARAMS is the set of `alg` +// values we will verify — `EdDSA` is deliberately absent from it. + +// The `alg` for every JWT this service signs. Its signing key is Ed25519. +export const SIGNING_ALG = 'Ed25519' const textEncoder = new TextEncoder() @@ -21,9 +32,11 @@ function base64urlDecode(str: string): Uint8Array { } export async function importSigningKey(jwkJson: string): Promise { - // Strip alg/key_ops/ext: a JWK exported by newer runtimes carries - // alg:"Ed25519", which WebCrypto rejects on import as an Ed25519 key - // (it expects alg absent or "EdDSA"). Usages come from the import call. + // Strip alg/key_ops/ext before WebCrypto import. This is a WebCrypto quirk, + // not a JOSE one: importKey with {name:'Ed25519'} rejects a JWK carrying + // alg:"Ed25519" (it only tolerates the alg being absent, or the legacy + // "EdDSA"). The curve comes from the import algorithm and the usages from + // the call, so nothing is lost. Everything we *emit* carries "Ed25519". const { alg: _alg, key_ops: _ops, ext: _ext, ...jwk } = JSON.parse(jwkJson) return crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, false, ['sign']) } @@ -32,10 +45,11 @@ import { calculateThumbprint } from '@hellocoop/httpsig' export { calculateThumbprint as computeJwkThumbprint } // Fully-specified JOSE algorithm identifier (RFC 9864) for a JWK. -// httpsig 2.x (signature-key -08) takes the algorithm from the key's `alg` -// member — never derived from kty/crv — and rejects keys without one, as -// well as the polymorphic "EdDSA". Every JWK we publish or put in a cnf -// claim must carry it. +// httpsig 2.x (signature-key -08) and AAuth -11 §Signature Algorithms both +// take the algorithm from the key's `alg` member — never derived from +// kty/crv — and reject keys without one, as well as the polymorphic +// "EdDSA". Every JWK we publish or put in a cnf claim must carry it, so an +// incoming "EdDSA" is discarded and re-derived from the curve. function fullySpecifiedAlg(jwk: JsonWebKey): string | undefined { if (jwk.alg && jwk.alg !== 'EdDSA') return jwk.alg if (jwk.kty === 'OKP') return jwk.crv // 'Ed25519' | 'Ed448' @@ -93,9 +107,19 @@ export function decodeJWTPayload(jwt: string): Record { return JSON.parse(new TextDecoder().decode(base64urlDecode(jwt.split('.')[1]))) } -// JWT alg → WebCrypto parameters +// Fully-specified JWT alg → WebCrypto parameters. AAuth §Signature +// Algorithms: implementations MUST NOT accept `none`, the polymorphic +// `EdDSA`, or any symmetric algorithm. `EdDSA` is therefore not a key here — +// a JWT signed with it fails with `unsupported alg: EdDSA` rather than being +// verified as Ed25519. `Ed25519` and `ES256` are already fully specified; +// `RS256` names its hash and padding, so it qualifies too. +// +// Flag-day blocker: the live person server still signs every AAuth token with +// `EdDSA` — `HelloCoop/Wallet/svr/issuer/sign.js:32`, `const alg = useEdDSA ? +// 'EdDSA' : 'RS256'` with `useEdDSA = isAAuthType(typ)`. That line must ship +// `Ed25519` in the same window as this, or auth tokens stop verifying here. const JWT_ALG_PARAMS: Record = { - EdDSA: { importAlgo: { name: 'Ed25519' }, verifyAlgo: 'Ed25519' }, + Ed25519: { importAlgo: { name: 'Ed25519' }, verifyAlgo: 'Ed25519' }, RS256: { importAlgo: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, verifyAlgo: 'RSASSA-PKCS1-v1_5', @@ -118,9 +142,16 @@ export async function verifyJWT( const algParams = JWT_ALG_PARAMS[header.alg] if (!algParams) throw new Error(`unsupported alg: ${header.alg}`) - const candidates = jwks.keys.filter((k) => - header.kid ? (k as { kid?: string }).kid === header.kid : true - ) + const candidates = jwks.keys.filter((k) => { + if (header.kid && (k as { kid?: string }).kid !== header.kid) return false + // A key whose algorithm disagrees with the header's is not a candidate + // (AAuth -11: reject a key whose alg, kty, or crv disagree). The key's + // algorithm is its `alg` where it has a fully-specified one, and the + // curve otherwise; only RSA-without-alg is indeterminate, and those keys + // are still tried — the WebCrypto import below rejects a real mismatch. + const keyAlg = fullySpecifiedAlg(k) + return !keyAlg || keyAlg === header.alg + }) if (candidates.length === 0) throw new Error('no matching key in JWKS') const signingInput = textEncoder.encode(`${headerB64}.${payloadB64}`) diff --git a/src/discoverability.ts b/src/discoverability.ts index a461837..0e518f9 100644 --- a/src/discoverability.ts +++ b/src/discoverability.ts @@ -41,5 +41,31 @@ export function llmsTxt(origin: string): string { Each entry caches the resource's name, description, and access_mode; full, current metadata is always at the resource's own /.well-known/aauth-resource.json. + +### access_mode + +The credential flow a resource expects on a first call: + +- \`agent-token\` — the resource authorizes on the agent's identity alone +- \`person-token\` — the resource authorizes on the person's identity alone +- \`session-token\` — the resource manages authorization itself and issues a + session token, returned in the AAuth-Access header +- \`auth-token\` — the agent obtains an auth token from its person server using + a resource token; the initial call presents a person token +- \`per-call\` — each invocation is authorized individually against that call's + parameters (defined by AAuth R3) + +This is an IANA registry (AAuth Access Mode Value Registry, Specification +Required), so the list can grow. An agent that meets a value it does not +recognize proceeds exactly as it would with no declaration: call the resource +and read the AAuth-Requirement that comes back. The declaration is advisory — +the runtime requirement is authoritative. + +A resource may also state the mode per operation rather than for the whole +resource, as an R3 operation access annotation on the operation in its own +vocabulary (its OpenAPI document, MCP tool list, AsyncAPI document, or OData +metadata document). Those annotations replace the resource-wide access_mode for the +operations they cover. This registry lists only the resource-wide value; read +the resource's vocabulary for per-operation detail. ` } diff --git a/src/login.ts b/src/login.ts index 499e7e9..164569d 100644 --- a/src/login.ts +++ b/src/login.ts @@ -1,10 +1,25 @@ // "Login with Hellō" and submitter identity — both built on the AAuth -// auth-token flow (no OIDC). A browser web-agent or an agent calls signed -// with its agent token; the registry returns a resource_token (401 + -// AAuth-Requirement). The caller takes it to the Person Server (Hellō), -// the human approves there — the interaction — and the PS returns an -// auth_token. Retried with the auth_token, the registry verifies it -// against the PS JWKS and reads the verified person (sub/email/name). +// auth-token flow (no OIDC). Three signed calls, each a step up the ladder: +// +// 1. agent token → 401 `requirement=person-token`. The registry will not +// issue a resource token to an agent that cannot name a person. +// 2. person token → verified against the PS's JWKS, then 401 +// `requirement=auth-token` carrying a resource token that copies the +// person token's `ps`, `sub`, and `jti`. +// 3. auth token → verified against the PS's JWKS; the registry reads the +// verified person (sub/email/name) and, at /auth/identity, sets a session. +// +// Step 1 is what -11 added here (§Resource Access and Resource Tokens): "A +// resource MUST have verified a person token before it issues a resource +// token, and MUST challenge with `requirement=person-token` when it has not." +// Only a person server can redeem a resource token — it is the `aud` in +// three-party and the only party that may call an AS token endpoint — so a +// resource token naming no person is one nobody can act on. Before this step +// existed the registry minted resource tokens straight off an agent token and +// had nothing to put in `ps`/`sub`/`person_token_jti`. +// +// This does not change the registry's own `access_mode`: listing resources +// still needs only an agent token. It is the login flow that climbs. // // handleIdentity — GET /auth/identity: on auth_token, set a session. // resolveSubmitter — POST /resources: require a verified person to add. @@ -23,9 +38,9 @@ import { importSigningKey, signJWT, verifyJWT, + SIGNING_ALG, } from './crypto' import { mintSessionCookie, readSession } from './session' -import { issuerJwks } from './agent-token' import { emit, emitVerifyFailed } from './events' import type { Env, SubmitterIdentity } from './types' @@ -34,6 +49,28 @@ type HonoEnv = { Bindings: Env } // Identity scopes the registry asks the PS to release (verified email + name). const LOGIN_SCOPE = 'openid email name' +// The verified person behind a person token, plus the token's own jti so the +// resource token can bind to it. +interface VerifiedPersonToken { + ps: string // the person token's iss — the PS that issued it + sub: string // directed identifier in that PS's namespace + jti: string // person_token_jti — binds the resource token to this token +} + +// Resolve a person/auth-token issuer's JWKS. Per signature-key -08 the +// metadata document must name the identity it is served under, byte-equal, +// before its jwks_uri is trusted. +async function psJwks(iss: string, dwk: string): Promise<{ keys: JsonWebKey[] }> { + const metaRes = await fetch(`${iss}/.well-known/${dwk}`) + if (!metaRes.ok) throw new Error(`PS metadata: ${metaRes.status}`) + const meta = (await metaRes.json()) as Record + if (meta.issuer !== iss) throw new Error('PS metadata issuer mismatch') + if (!meta.jwks_uri) throw new Error('PS metadata missing jwks_uri') + const jwksRes = await fetch(meta.jwks_uri as string) + if (!jwksRes.ok) throw new Error(`PS JWKS: ${jwksRes.status}`) + return (await jwksRes.json()) as { keys: JsonWebKey[] } +} + // 401 returned when no signature is present, telling the caller what to sign. function signatureRequired(c: Context): Response { emitVerifyFailed(c, 'no_signature') @@ -65,16 +102,7 @@ async function verifyAuthTokenIdentity( let jwks: { keys: JsonWebKey[] } try { - const metaRes = await fetch(`${iss}/.well-known/${dwk}`) - if (!metaRes.ok) return c.json({ error: `PS metadata: ${metaRes.status}` }, 502) as unknown as Response - const meta = (await metaRes.json()) as Record - // signature-key -08: metadata must name the identity it is served under - // (byte-equal) before its jwks_uri is trusted. - if (meta.issuer !== iss) return c.json({ error: 'PS metadata issuer mismatch' }, 502) as unknown as Response - if (!meta.jwks_uri) return c.json({ error: 'PS metadata missing jwks_uri' }, 502) as unknown as Response - const jwksRes = await fetch(meta.jwks_uri as string) - if (!jwksRes.ok) return c.json({ error: `PS JWKS: ${jwksRes.status}` }, 502) as unknown as Response - jwks = (await jwksRes.json()) as { keys: JsonWebKey[] } + jwks = await psJwks(iss, dwk) } catch (err) { return c.json({ error: `cannot reach PS: ${(err as Error).message}` }, 502) as unknown as Response } @@ -106,63 +134,125 @@ async function verifyAuthTokenIdentity( } } -// Verify an agent_token against its provider's JWKS, then mint a -// resource_token and return a 401 auth-token challenge (the consent step). -async function challengeForAuthToken( +// 401 telling an agent to come back with a person token. The header carries +// no parameters (-11 §Person Token Required) — the agent gets one for this +// resource from its PS's person token endpoint and retries. An agent with no +// person server cannot satisfy this and surfaces it as an error. +function personTokenRequired(c: Context, reason: string): Response { + emit(c, { + event: 'aauth.registry.person_token_challenge', + msg: `person token required: ${reason}`, + reason, + }) + return c.json( + { error: 'person_token_required' }, + { status: 401, headers: { 'AAuth-Requirement': 'requirement=person-token' } }, + ) as unknown as Response +} + +// Verify a person token per -11 §Person Token Verification. `jkt` is the +// thumbprint of the key that signed the HTTP request. Returns the facts the +// resource token must copy, or a 400 `invalid_person_token` Response. +async function verifyPersonToken( c: Context, raw: string, payload: Record, -): Promise { - const agentIss = payload.iss as string | undefined - const agentDwk = (payload.dwk as string) || 'aauth-agent.json' - if (!agentIss) return c.json({ error: 'agent_token missing iss' }, 401) + jkt: string, +): Promise { + const bad = (detail: string, extra: Record = {}) => { + emitVerifyFailed(c, 'invalid_person_token', { detail, ...extra }) + return c.json({ error: 'invalid_person_token', detail }, 400) as unknown as Response + } + + // Every check that reads only the payload runs first, so a structurally + // wrong token is refused without the registry making an outbound request + // on its say-so. Signature verification, the one step that costs two + // fetches to a host named inside the token, goes last. + // 2. dwk is fixed for this token type; key discovery hangs off it. + const dwk = payload.dwk as string | undefined + if (dwk !== 'aauth-person.json') return bad(`dwk must be aauth-person.json, got ${dwk ?? 'none'}`) + + // 4. iss must be an https URL with no query or fragment (§Server Identifiers). + const iss = payload.iss as string | undefined + if (!iss) return bad('missing iss') try { - const jwks = await issuerJwks(c.env, agentIss, agentDwk) - await verifyJWT(raw, jwks) - } catch (err) { - emitVerifyFailed(c, 'agent_token_jwt_verify_failed', { iss: agentIss, detail: (err as Error).message }) - return c.json({ error: `agent_token verification failed: ${(err as Error).message}` }, 401) + const u = new URL(iss) + if (u.protocol !== 'https:' || u.search || u.hash) return bad(`iss is not a server identifier: ${iss}`) + } catch { + return bad(`iss is not a URL: ${iss}`) } + // 3. exp in the future, iat not in the future. const now = Math.floor(Date.now() / 1000) - if (!payload.exp || (payload.exp as number) < now) { - return c.json({ error: 'agent_token expired' }, 401) + if (!payload.exp || (payload.exp as number) < now) return bad('expired', { iss }) + if (payload.iat && (payload.iat as number) > now + 60) return bad('iat in the future', { iss }) + + // 5. aud is this resource. + if (payload.aud !== c.env.ORIGIN) { + return bad(`aud mismatch: expected ${c.env.ORIGIN}, got ${String(payload.aud)}`, { iss }) } - const psUrl = payload.ps as string | undefined - if (!psUrl) return c.json({ error: 'agent_token missing ps claim — cannot establish identity' }, 400) + // 6. cnf.jwk is REQUIRED and must be the key that signed the request. + const cnf = payload.cnf as { jwk?: JsonWebKey } | undefined + if (!cnf?.jwk) return bad('missing cnf.jwk', { iss }) + if ((await computeJwkThumbprint(cnf.jwk)) !== jkt) { + return bad('cnf.jwk is not the key that signed the request', { iss }) + } + + const sub = payload.sub as string | undefined + if (!sub) return bad('missing sub', { iss }) + const jti = payload.jti as string | undefined + if (!jti) return bad('missing jti', { iss }) + + // A person token MUST NOT carry scope or account. + if (payload.scope !== undefined || payload.account !== undefined) { + return bad('person token carries scope or account', { iss }) + } - let psIssuer: string + // 2 (cont.). Discover the PS JWKS and verify the signature. try { - const psRes = await fetch(`${psUrl}/.well-known/aauth-person.json`) - if (!psRes.ok) return c.json({ error: `PS metadata: ${psRes.status}` }, 502) - const psMeta = (await psRes.json()) as Record - if (!psMeta.issuer) return c.json({ error: 'PS metadata missing issuer' }, 502) - // Byte-equal issuer check (signature-key -08 / RFC 8414 §3.3): don't - // mint a resource_token aud'd to an issuer the ps host didn't prove. - if (psMeta.issuer !== psUrl) return c.json({ error: 'PS metadata issuer mismatch' }, 502) - psIssuer = psMeta.issuer as string + await verifyJWT(raw, await psJwks(iss, dwk)) } catch (err) { - return c.json({ error: `cannot reach PS: ${(err as Error).message}` }, 502) + return bad(`signature: ${(err as Error).message}`, { iss }) } - const cnf = payload.cnf as { jwk: JsonWebKey } | undefined - if (!cnf?.jwk) return c.json({ error: 'agent_token missing cnf.jwk' }, 400) - const agentJkt = await computeJwkThumbprint(cnf.jwk) + return { ps: iss, sub, jti } +} +// Mint a resource token for a verified person and return the 401 auth-token +// challenge (the consent step). `aud` is the PS that issued the person token: +// it is the party that can redeem this, and its identity was proved by the +// byte-equal issuer check in psJwks() during verification. +async function challengeForAuthToken( + c: Context, + person: VerifiedPersonToken, + agentJkt: string, +): Promise { + const now = Math.floor(Date.now() / 1000) const origin = c.env.ORIGIN const privateKey = await importSigningKey(c.env.SIGNING_KEY) const publicJwk = await getPublicJWK(c.env.SIGNING_KEY) + // AAuth -11 §Resource Token Structure. + // + // - No `agent` claim. A resource token carries no agent identifier; + // `agent_jkt` binds it to the agent's key, and the PS learns which agent + // is asking from the agent token that signs the token request. + // - `ps`, `sub`, `person_token_jti` are copied unchanged from the person + // token this registry verified. The PS looks the person token up by + // `person_token_jti` among those it issued and rejects the resource + // token on any mismatch, so these are not free-form assertions. const resourceToken = await signJWT( - { alg: 'EdDSA', typ: 'aa-resource+jwt', kid: publicJwk.kid }, + { alg: SIGNING_ALG, typ: 'aa-resource+jwt', kid: publicJwk.kid }, { iss: origin, dwk: 'aauth-resource.json', - aud: psIssuer, + aud: person.ps, jti: generateJTI(), - agent: payload.sub as string, + ps: person.ps, + sub: person.sub, + person_token_jti: person.jti, agent_jkt: agentJkt, scope: LOGIN_SCOPE, iat: now, @@ -174,8 +264,8 @@ async function challengeForAuthToken( emit(c, { event: 'aauth.registry.auth_token_challenge', msg: 'resource_token minted; auth-token (consent) required', - agent: payload.sub, - ps: psUrl, + ps: person.ps, + user: person.sub, }) return c.json( @@ -189,12 +279,18 @@ async function challengeForAuthToken( ) as unknown as Response } -// Run httpSigVerify and return the inner JWT (header/payload/raw), or a -// Response on any signature failure. +// Run httpSigVerify and return the inner JWT (header/payload/raw) plus the +// thumbprint of the key that signed the request, or a Response on any +// signature failure. Under sig=jwt that key comes from the token's own +// `cnf.jwk`, so the thumbprint is what a `cnf` must match and what goes in a +// resource token's `agent_jkt`. async function verifiedJwt( c: Context, rawBody?: string, -): Promise<{ header: Record; payload: Record; raw: string } | Response> { +): Promise< + | { header: Record; payload: Record; raw: string; jkt: string } + | Response +> { const url = new URL(c.req.url) const sigResult = await httpSigVerify({ method: c.req.method, @@ -221,11 +317,13 @@ async function verifiedJwt( header: sigResult.jwt.header as Record, payload: sigResult.jwt.payload as Record, raw: sigResult.jwt.raw, + jkt: sigResult.thumbprint, } } -// GET /auth/identity — the human login endpoint. agent_token → challenge; -// auth_token → verify, set session cookie, return claims. +// GET /auth/identity — the human login endpoint, one rung per call: +// agent_token → person-token challenge; person_token → resource-token +// challenge; auth_token → verify, set session cookie, return claims. export async function handleIdentity(c: Context): Promise { const v = await verifiedJwt(c) if (v instanceof Response) return v @@ -237,8 +335,13 @@ export async function handleIdentity(c: Context): Promise { emit(c, { event: 'aauth.registry.login', msg: `human logged in: ${id.sub}`, user: id.sub, ps: id.ps, email: id.email }) return c.json({ status: 'logged_in', ...id }, 200, { 'Set-Cookie': cookie }) } + if (v.header.typ === 'aa-person+jwt') { + const person = await verifyPersonToken(c, v.raw, v.payload, v.jkt) + if (person instanceof Response) return person + return challengeForAuthToken(c, person, v.jkt) + } if (v.header.typ === 'aa-agent+jwt') { - return challengeForAuthToken(c, v.raw, v.payload) + return personTokenRequired(c, 'agent token presented at /auth/identity') } emitVerifyFailed(c, 'unsupported_jwt_type', { jwt_typ: v.header.typ }) return c.json({ error: `unsupported JWT type: ${v.header.typ}` }, 400) @@ -252,8 +355,15 @@ export interface ResolvedSubmitter { // Resolve the verified person behind a POST /resources call: // - auth_token signature → identity from the token // - agent_token + valid session → identity from the web session -// - agent_token, no session → 401 auth-token challenge (consent) +// - agent_token, no session → 401 person-token challenge +// - person_token → 401 auth-token challenge (consent) // Returns the submitter, or a Response (challenge / error) to return as-is. +// +// A person token alone is not enough to attribute an entry: it names the +// person as a directed `(iss, sub)` pair and deliberately carries no scope, +// so it cannot release the verified email and name an entry is credited +// with. It is the rung that earns the resource token; the auth token that +// comes back carries the claims. export async function resolveSubmitter( c: Context, rawBody: string, @@ -268,13 +378,19 @@ export async function resolveSubmitter( return { user: id, agent: act?.sub } } + if (v.header.typ === 'aa-person+jwt') { + const person = await verifyPersonToken(c, v.raw, v.payload, v.jkt) + if (person instanceof Response) return person + return challengeForAuthToken(c, person, v.jkt) + } + if (v.header.typ === 'aa-agent+jwt') { // A logged-in human (web UI): the web-agent signs and the session // cookie carries the verified person established at login. const session = await readSession(c) if (session) return { user: session, agent: v.payload.sub as string } - // Otherwise the agent must obtain an auth_token → trigger consent. - return challengeForAuthToken(c, v.raw, v.payload) + // Otherwise start the climb: person token first. + return personTokenRequired(c, 'agent token presented at POST /resources') } emitVerifyFailed(c, 'unsupported_jwt_type', { jwt_typ: v.header.typ }) diff --git a/src/session.ts b/src/session.ts index 489d9ad..8b8035a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -5,7 +5,7 @@ // JWK), so no extra secret is needed. import type { Context } from 'hono' -import { getPublicJWK, importSigningKey, signJWT, verifyJWT } from './crypto' +import { getPublicJWK, importSigningKey, signJWT, verifyJWT, SIGNING_ALG } from './crypto' import type { Env, SubmitterIdentity } from './types' type HonoEnv = { Bindings: Env } @@ -20,7 +20,11 @@ export async function mintSessionCookie(env: Env, id: HumanIdentity): Promise { const url = `${origin}/.well-known/aauth-resource.json` @@ -122,7 +141,7 @@ export function buildEntry( issuer: meta.issuer, name: meta.name?.trim() || meta.issuer, description: meta.description!.trim(), - access_mode: (meta.access_mode as AccessMode) ?? 'agent-token', + access_mode: (meta.access_mode as AccessMode) ?? DEFAULT_ACCESS_MODE, ...(meta.documentation_uri ? { documentation_uri: meta.documentation_uri } : {}), added: new Date().toISOString(), submitted_by, From f33585ce254f292329dd0dc413aa024d145516cb Mon Sep 17 00:00:00 2001 From: dickhardt Date: Thu, 13 Aug 2026 16:05:46 +0100 Subject: [PATCH 2/2] =?UTF-8?q?Adopt=20httpsig=202.2.0=20content-digest=20?= =?UTF-8?q?defaults;=20enforce=20=C2=A710.3=20at=20/bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump @hellocoop/httpsig to ^2.2.0 and apply AAuth -11 §10.3: body-carrying requests to token-issuing endpoints MUST cover content-digest and content-type; resources are exempt. - client/registry.js: drop the hand-rolled component lists in signedFetch and bootstrap — httpsig 2.2's defaults are the same lists, and its contentDigest: 'auto' default appends content-digest for string bodies. That makes the browser agent's POSTs to PS token endpoints (person_token, auth_token) §10.3-compliant. public/registry.js rebuilt. - src/ap.ts: /bootstrap mints agent tokens, so verifySigHwk now passes requireContentDigest: true. - src/agent-token.ts: the /resources API stays exempt (resource role) — verification unchanged — but the with-body Accept-Signature challenge now advertises content-digest + content-type, matching what a 2.2 client signs by default. - src/login.ts: unchanged — GET /auth/identity is bodyless and POST /resources is the resource role. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TQ2FCHHAnuJWF5TJB3838S --- client/registry.js | 12 +- package-lock.json | 8 +- package.json | 2 +- public/registry.js | 1040 +++++++++++++++++++++++++++++++++++++------- src/agent-token.ts | 7 +- src/ap.ts | 22 +- 6 files changed, 913 insertions(+), 178 deletions(-) diff --git a/client/registry.js b/client/registry.js index 9c3ec84..1a78846 100644 --- a/client/registry.js +++ b/client/registry.js @@ -83,13 +83,14 @@ async function publicJwk(kp) { } // sig=jwt call (agent_token or auth_token), with optional body + headers. +// No explicit component list: httpsig 2.2's defaults are exactly the lists +// this used to hand-roll, and its contentDigest: 'auto' default appends +// content-digest for our string bodies — which §10.3 requires on the +// body-carrying calls this makes to PS token endpoints. async function signedFetch(url, { method = 'GET', body, jwt, headers = {} } = {}) { const kp = await getKeyPair() const pub = await publicJwk(kp) const hasBody = body != null - const components = hasBody - ? ['@method', '@authority', '@path', 'content-type', 'signature-key'] - : ['@method', '@authority', '@path', 'signature-key'] // sigFetch returns a plain Response unless returnSent is set. return sigFetch(url, { method, @@ -98,7 +99,6 @@ async function signedFetch(url, { method = 'GET', body, jwt, headers = {} } = {} signingKey: pub, signingCryptoKey: kp.privateKey, signatureKey: { type: 'jwt', jwt }, - components, }) } @@ -106,6 +106,9 @@ async function signedFetch(url, { method = 'GET', body, jwt, headers = {} } = {} async function bootstrap() { const kp = await getKeyPair() const pub = await publicJwk(kp) + // Default components + contentDigest: 'auto' cover content-digest and + // content-type over the JSON body, which /bootstrap requires (it mints + // tokens, so it enforces §10.3 coverage). const response = await sigFetch(`${ORIGIN}/bootstrap`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -113,7 +116,6 @@ async function bootstrap() { signingKey: pub, signingCryptoKey: kp.privateKey, signatureKey: { type: 'hwk' }, - components: ['@method', '@authority', '@path', 'content-type', 'signature-key'], }) const data = await response.json() if (!response.ok || !data.agent_token) throw new Error(data.error || 'bootstrap failed') diff --git a/package-lock.json b/package-lock.json index 6559f71..ce4c850 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "registry.aauth.dev", "version": "0.1.0", "dependencies": { - "@hellocoop/httpsig": "^2.0.0", + "@hellocoop/httpsig": "^2.2.0", "hono": "^4.7.0" }, "devDependencies": { @@ -603,9 +603,9 @@ } }, "node_modules/@hellocoop/httpsig": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-2.0.1.tgz", - "integrity": "sha512-nmAI+A3YQKOfeZ9cnKjtNXVVJ6sUwazwhM0zQV/t8XpbMKJdYoj3IfaTph9LUMlAvBlDN70wJ2jnUYOr0GQW6g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-2.2.0.tgz", + "integrity": "sha512-UdWonQL79Nb/NmY5YKSmcdaC/Drjx5lINixdDk8yr2WN93q2zmAV28U/aV5YW35fZWu3n3G0cIavygNKoUPekA==", "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 91abac1..a5b1199 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:deploy": "bash scripts/test.sh" }, "dependencies": { - "@hellocoop/httpsig": "^2.0.0", + "@hellocoop/httpsig": "^2.2.0", "hono": "^4.7.0" }, "devDependencies": { diff --git a/public/registry.js b/public/registry.js index 1cc06b8..cbf84ec 100644 --- a/public/registry.js +++ b/public/registry.js @@ -26,9 +26,9 @@ mod )); - // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/types.js + // node_modules/@hellocoop/httpsig/dist/types.js var require_types = __commonJS({ - "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/types.js"(exports) { + "node_modules/@hellocoop/httpsig/dist/types.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.DEFAULT_COMPONENTS_BODY = exports.DEFAULT_COMPONENTS_GET = exports.VALID_DERIVED_COMPONENTS = void 0; @@ -59,9 +59,9 @@ } }); - // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/errors.js + // node_modules/@hellocoop/httpsig/dist/errors.js var require_errors = __commonJS({ - "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/errors.js"(exports) { + "node_modules/@hellocoop/httpsig/dist/errors.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SignatureVerificationError = void 0; @@ -117,9 +117,9 @@ } }); - // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/crypto.js + // node_modules/@hellocoop/httpsig/dist/utils/crypto.js var require_crypto = __commonJS({ - "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/crypto.js"(exports) { + "node_modules/@hellocoop/httpsig/dist/utils/crypto.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.UNIMPLEMENTED_ALGORITHMS = exports.SYMMETRIC_ALGORITHMS = exports.POLYMORPHIC_ALGORITHMS = exports.SUPPORTED_ALGORITHMS = exports.FULLY_SPECIFIED_ALGORITHMS = void 0; @@ -290,9 +290,9 @@ } }); - // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/base64.js + // node_modules/@hellocoop/httpsig/dist/utils/base64.js var require_base64 = __commonJS({ - "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/base64.js"(exports) { + "node_modules/@hellocoop/httpsig/dist/utils/base64.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.base64urlEncode = base64urlEncode; @@ -350,13 +350,620 @@ } }); - // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/signature.js + // node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/types.js + var require_types2 = __commonJS({ + "node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/types.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.ByteSequence = void 0; + var ByteSequence = class { + base64Value; + constructor(base64Value) { + this.base64Value = base64Value; + } + toBase64() { + return this.base64Value; + } + }; + exports.ByteSequence = ByteSequence; + } + }); + + // node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/util.js + var require_util = __commonJS({ + "node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/util.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.isAscii = isAscii; + exports.isValidTokenStr = isValidTokenStr; + exports.isValidKeyStr = isValidKeyStr; + exports.isInnerList = isInnerList; + exports.isByteSequence = isByteSequence; + var asciiRe = /^[\x20-\x7E]*$/; + var tokenRe = /^[a-zA-Z*][:/!#$%&'*+\-.^_`|~A-Za-z0-9]*$/; + var keyRe = /^[a-z*][*\-_.a-z0-9]*$/; + function isAscii(str) { + return asciiRe.test(str); + } + function isValidTokenStr(str) { + return tokenRe.test(str); + } + function isValidKeyStr(str) { + return keyRe.test(str); + } + function isInnerList(input) { + return Array.isArray(input[0]); + } + function isByteSequence(input) { + return typeof input === "object" && "base64Value" in input; + } + } + }); + + // node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/token.js + var require_token = __commonJS({ + "node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/token.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.Token = void 0; + var util_js_1 = require_util(); + var Token = class { + value; + constructor(value) { + if (!(0, util_js_1.isValidTokenStr)(value)) { + throw new TypeError("Invalid character in Token string. Tokens must start with *, A-Z and the rest of the string may only contain a-z, A-Z, 0-9, :/!#$%&'*+-.^_`|~"); + } + this.value = value; + } + toString() { + return this.value; + } + }; + exports.Token = Token; + } + }); + + // node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/parser.js + var require_parser = __commonJS({ + "node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/parser.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.ParseError = void 0; + exports.parseDictionary = parseDictionary; + exports.parseList = parseList; + exports.parseItem = parseItem; + var types_js_1 = require_types2(); + var token_js_1 = require_token(); + var util_js_1 = require_util(); + function parseDictionary(input) { + const parser = new Parser(input); + return parser.parseDictionary(); + } + function parseList(input) { + const parser = new Parser(input); + return parser.parseList(); + } + function parseItem(input) { + const parser = new Parser(input); + return parser.parseItem(); + } + var ParseError = class extends Error { + constructor(position, message) { + super(`Parse error: ${message} at offset ${position}`); + } + }; + exports.ParseError = ParseError; + var Parser = class { + input; + pos; + constructor(input) { + this.input = input; + this.pos = 0; + } + parseDictionary() { + this.skipWS(); + const dictionary = /* @__PURE__ */ new Map(); + while (!this.eof()) { + const thisKey = this.parseKey(); + let member; + if (this.lookChar() === "=") { + this.pos++; + member = this.parseItemOrInnerList(); + } else { + member = [true, this.parseParameters()]; + } + dictionary.set(thisKey, member); + this.skipOWS(); + if (this.eof()) { + return dictionary; + } + this.expectChar(","); + this.pos++; + this.skipOWS(); + if (this.eof()) { + throw new ParseError(this.pos, "Dictionary contained a trailing comma"); + } + } + return dictionary; + } + parseList() { + this.skipWS(); + const members = []; + while (!this.eof()) { + members.push(this.parseItemOrInnerList()); + this.skipOWS(); + if (this.eof()) { + return members; + } + this.expectChar(","); + this.pos++; + this.skipOWS(); + if (this.eof()) { + throw new ParseError(this.pos, "A list may not end with a trailing comma"); + } + } + return members; + } + parseItem(standaloneItem = true) { + if (standaloneItem) + this.skipWS(); + const result = [this.parseBareItem(), this.parseParameters()]; + if (standaloneItem) + this.checkTrail(); + return result; + } + parseItemOrInnerList() { + if (this.lookChar() === "(") { + return this.parseInnerList(); + } else { + return this.parseItem(false); + } + } + parseInnerList() { + this.expectChar("("); + this.pos++; + const innerList = []; + while (!this.eof()) { + this.skipWS(); + if (this.lookChar() === ")") { + this.pos++; + return [innerList, this.parseParameters()]; + } + innerList.push(this.parseItem(false)); + const nextChar = this.lookChar(); + if (nextChar !== " " && nextChar !== ")") { + throw new ParseError(this.pos, "Expected a whitespace or ) after every item in an inner list"); + } + } + throw new ParseError(this.pos, "Could not find end of inner list"); + } + parseBareItem() { + const char = this.lookChar(); + if (char === void 0) { + throw new ParseError(this.pos, "Unexpected end of string"); + } + if (char.match(/^[-0-9]/)) { + return this.parseIntegerOrDecimal(); + } + if (char === '"') { + return this.parseString(); + } + if (char.match(/^[A-Za-z*]/)) { + return this.parseToken(); + } + if (char === ":") { + return this.parseByteSequence(); + } + if (char === "?") { + return this.parseBoolean(); + } + throw new ParseError(this.pos, "Unexpected input"); + } + parseParameters() { + const parameters = /* @__PURE__ */ new Map(); + while (!this.eof()) { + const char = this.lookChar(); + if (char !== ";") { + break; + } + this.pos++; + this.skipWS(); + const key = this.parseKey(); + let value = true; + if (this.lookChar() === "=") { + this.pos++; + value = this.parseBareItem(); + } + parameters.set(key, value); + } + return parameters; + } + parseIntegerOrDecimal() { + let type = "integer"; + let sign = 1; + let inputNumber = ""; + if (this.lookChar() === "-") { + sign = -1; + this.pos++; + } + if (!isDigit(this.lookChar())) { + throw new ParseError(this.pos, "Expected a digit (0-9)"); + } + while (!this.eof()) { + const char = this.getChar(); + if (isDigit(char)) { + inputNumber += char; + } else if (type === "integer" && char === ".") { + if (inputNumber.length > 12) { + throw new ParseError(this.pos, "Exceeded maximum decimal length"); + } + inputNumber += "."; + type = "decimal"; + } else { + this.pos--; + break; + } + if (type === "integer" && inputNumber.length > 15) { + throw new ParseError(this.pos, "Exceeded maximum integer length"); + } + if (type === "decimal" && inputNumber.length > 16) { + throw new ParseError(this.pos, "Exceeded maximum decimal length"); + } + } + if (type === "integer") { + return parseInt(inputNumber, 10) * sign; + } else { + if (inputNumber.endsWith(".")) { + throw new ParseError(this.pos, "Decimal cannot end on a period"); + } + if (inputNumber.split(".")[1].length > 3) { + throw new ParseError(this.pos, "Number of digits after the decimal point cannot exceed 3"); + } + return parseFloat(inputNumber) * sign; + } + } + parseString() { + let outputString = ""; + this.expectChar('"'); + this.pos++; + while (!this.eof()) { + const char = this.getChar(); + if (char === "\\") { + if (this.eof()) { + throw new ParseError(this.pos, "Unexpected end of input"); + } + const nextChar = this.getChar(); + if (nextChar !== "\\" && nextChar !== '"') { + throw new ParseError(this.pos, "A backslash must be followed by another backslash or double quote"); + } + outputString += nextChar; + } else if (char === '"') { + return outputString; + } else if (!(0, util_js_1.isAscii)(char)) { + throw new ParseError(this.pos, "Strings must be in the ASCII range"); + } else { + outputString += char; + } + } + throw new ParseError(this.pos, "Unexpected end of input"); + } + parseToken() { + let outputString = ""; + while (!this.eof()) { + const char = this.lookChar(); + if (char === void 0 || !/^[:/!#$%&'*+\-.^_`|~A-Za-z0-9]$/.test(char)) { + return new token_js_1.Token(outputString); + } + outputString += this.getChar(); + } + return new token_js_1.Token(outputString); + } + parseByteSequence() { + this.expectChar(":"); + this.pos++; + const endPos = this.input.indexOf(":", this.pos); + if (endPos === -1) { + throw new ParseError(this.pos, 'Could not find a closing ":" character to mark end of Byte Sequence'); + } + const b64Content = this.input.substring(this.pos, endPos); + this.pos += b64Content.length + 1; + if (!/^[A-Za-z0-9+/=]*$/.test(b64Content)) { + throw new ParseError(this.pos, "ByteSequence does not contain a valid base64 string"); + } + return new types_js_1.ByteSequence(b64Content); + } + parseBoolean() { + this.expectChar("?"); + this.pos++; + const char = this.getChar(); + if (char === "1") { + return true; + } + if (char === "0") { + return false; + } + throw new ParseError(this.pos, 'Unexpected character. Expected a "1" or a "0"'); + } + parseKey() { + if (!this.lookChar()?.match(/^[a-z*]/)) { + throw new ParseError(this.pos, "A key must begin with an asterisk or letter (a-z)"); + } + let outputString = ""; + while (!this.eof()) { + const char = this.lookChar(); + if (char === void 0 || !/^[a-z0-9_\-.*]$/.test(char)) { + return outputString; + } + outputString += this.getChar(); + } + return outputString; + } + /** + * Looks at the next character without advancing the cursor. + * + * Returns undefined if we were at the end of the string. + */ + lookChar() { + return this.input[this.pos]; + } + /** + * Checks if the next character is 'char', and fail otherwise. + */ + expectChar(char) { + if (this.lookChar() !== char) { + throw new ParseError(this.pos, `Expected ${char}`); + } + } + getChar() { + return this.input[this.pos++]; + } + eof() { + return this.pos >= this.input.length; + } + // Advances the pointer to skip all whitespace. + skipOWS() { + while (true) { + const c = this.input.substr(this.pos, 1); + if (c === " " || c === " ") { + this.pos++; + } else { + break; + } + } + } + // Advances the pointer to skip all spaces + skipWS() { + while (this.lookChar() === " ") { + this.pos++; + } + } + // At the end of parsing, we need to make sure there are no bytes after the + // header except whitespace. + checkTrail() { + this.skipWS(); + if (!this.eof()) { + throw new ParseError(this.pos, "Unexpected characters at end of input"); + } + } + }; + exports.default = Parser; + var isDigitRegex = /^[0-9]$/; + function isDigit(char) { + if (char === void 0) + return false; + return isDigitRegex.test(char); + } + } + }); + + // node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/serializer.js + var require_serializer = __commonJS({ + "node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/serializer.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.SerializeError = void 0; + exports.serializeList = serializeList; + exports.serializeDictionary = serializeDictionary; + exports.serializeItem = serializeItem; + exports.serializeInnerList = serializeInnerList; + exports.serializeBareItem = serializeBareItem; + exports.serializeInteger = serializeInteger; + exports.serializeDecimal = serializeDecimal; + exports.serializeString = serializeString; + exports.serializeBoolean = serializeBoolean; + exports.serializeByteSequence = serializeByteSequence; + exports.serializeToken = serializeToken; + exports.serializeParameters = serializeParameters; + exports.serializeKey = serializeKey; + var types_js_1 = require_types2(); + var token_js_1 = require_token(); + var util_js_1 = require_util(); + var SerializeError = class extends Error { + }; + exports.SerializeError = SerializeError; + function serializeList(input) { + return input.map((value) => { + if ((0, util_js_1.isInnerList)(value)) { + return serializeInnerList(value); + } else { + return serializeItem(value); + } + }).join(", "); + } + function serializeDictionary(input) { + return Array.from(input.entries()).map(([key, value]) => { + let out = serializeKey(key); + if (value[0] === true) { + out += serializeParameters(value[1]); + } else { + out += "="; + if ((0, util_js_1.isInnerList)(value)) { + out += serializeInnerList(value); + } else { + out += serializeItem(value); + } + } + return out; + }).join(", "); + } + function serializeItem(input) { + return serializeBareItem(input[0]) + serializeParameters(input[1]); + } + function serializeInnerList(input) { + return `(${input[0].map((value) => serializeItem(value)).join(" ")})${serializeParameters(input[1])}`; + } + function serializeBareItem(input) { + if (typeof input === "number") { + if (Number.isInteger(input)) { + return serializeInteger(input); + } + return serializeDecimal(input); + } + if (typeof input === "string") { + return serializeString(input); + } + if (input instanceof token_js_1.Token) { + return serializeToken(input); + } + if (input instanceof types_js_1.ByteSequence) { + return serializeByteSequence(input); + } + if (typeof input === "boolean") { + return serializeBoolean(input); + } + throw new SerializeError(`Cannot serialize values of type ${typeof input}`); + } + function serializeInteger(input) { + if (input < -999999999999999 || input > 999999999999999) { + throw new SerializeError("Structured headers can only encode integers in the range range of -999,999,999,999,999 to 999,999,999,999,999 inclusive"); + } + return input.toString(); + } + function serializeDecimal(input) { + const out = input.toFixed(3).replace(/0+$/, ""); + const signifantDigits = out.split(".")[0].replace("-", "").length; + if (signifantDigits > 12) { + throw new SerializeError("Fractional numbers are not allowed to have more than 12 significant digits before the decimal point"); + } + return out; + } + function serializeString(input) { + if (!(0, util_js_1.isAscii)(input)) { + throw new SerializeError("Only ASCII strings may be serialized"); + } + return `"${input.replace(/("|\\)/g, (v) => "\\" + v)}"`; + } + function serializeBoolean(input) { + return input ? "?1" : "?0"; + } + function serializeByteSequence(input) { + return `:${input.toBase64()}:`; + } + function serializeToken(input) { + return input.toString(); + } + function serializeParameters(input) { + return Array.from(input).map(([key, value]) => { + let out = ";" + serializeKey(key); + if (value !== true) { + out += "=" + serializeBareItem(value); + } + return out; + }).join(""); + } + function serializeKey(input) { + if (!(0, util_js_1.isValidKeyStr)(input)) { + throw new SerializeError("Keys in dictionaries must only contain lowercase letter, numbers, _-*. and must start with a letter or *"); + } + return input; + } + } + }); + + // node_modules/@hellocoop/httpsig/dist/structured-fields.js + var require_structured_fields = __commonJS({ + "node_modules/@hellocoop/httpsig/dist/structured-fields.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.isValidKeyStr = exports.isValidTokenStr = exports.isByteSequence = exports.isInnerList = exports.ByteSequence = exports.Token = exports.SerializeError = exports.serializeParameters = exports.serializeBareItem = exports.serializeInnerList = exports.serializeItem = exports.serializeList = exports.serializeDictionary = exports.ParseError = exports.parseItem = exports.parseList = exports.parseDictionary = void 0; + exports.bareItemToString = bareItemToString; + var parser_js_1 = require_parser(); + Object.defineProperty(exports, "parseDictionary", { enumerable: true, get: function() { + return parser_js_1.parseDictionary; + } }); + Object.defineProperty(exports, "parseList", { enumerable: true, get: function() { + return parser_js_1.parseList; + } }); + Object.defineProperty(exports, "parseItem", { enumerable: true, get: function() { + return parser_js_1.parseItem; + } }); + Object.defineProperty(exports, "ParseError", { enumerable: true, get: function() { + return parser_js_1.ParseError; + } }); + var serializer_js_1 = require_serializer(); + Object.defineProperty(exports, "serializeDictionary", { enumerable: true, get: function() { + return serializer_js_1.serializeDictionary; + } }); + Object.defineProperty(exports, "serializeList", { enumerable: true, get: function() { + return serializer_js_1.serializeList; + } }); + Object.defineProperty(exports, "serializeItem", { enumerable: true, get: function() { + return serializer_js_1.serializeItem; + } }); + Object.defineProperty(exports, "serializeInnerList", { enumerable: true, get: function() { + return serializer_js_1.serializeInnerList; + } }); + Object.defineProperty(exports, "serializeBareItem", { enumerable: true, get: function() { + return serializer_js_1.serializeBareItem; + } }); + Object.defineProperty(exports, "serializeParameters", { enumerable: true, get: function() { + return serializer_js_1.serializeParameters; + } }); + Object.defineProperty(exports, "SerializeError", { enumerable: true, get: function() { + return serializer_js_1.SerializeError; + } }); + var token_js_1 = require_token(); + Object.defineProperty(exports, "Token", { enumerable: true, get: function() { + return token_js_1.Token; + } }); + var types_js_1 = require_types2(); + Object.defineProperty(exports, "ByteSequence", { enumerable: true, get: function() { + return types_js_1.ByteSequence; + } }); + var util_js_1 = require_util(); + Object.defineProperty(exports, "isInnerList", { enumerable: true, get: function() { + return util_js_1.isInnerList; + } }); + Object.defineProperty(exports, "isByteSequence", { enumerable: true, get: function() { + return util_js_1.isByteSequence; + } }); + Object.defineProperty(exports, "isValidTokenStr", { enumerable: true, get: function() { + return util_js_1.isValidTokenStr; + } }); + Object.defineProperty(exports, "isValidKeyStr", { enumerable: true, get: function() { + return util_js_1.isValidKeyStr; + } }); + var token_js_2 = require_token(); + function bareItemToString(value) { + if (typeof value === "string") { + return value; + } + if (value instanceof token_js_2.Token) { + return value.toString(); + } + throw new TypeError(`Expected a Structured Field String or Token, got ${typeof value}`); + } + } + }); + + // node_modules/@hellocoop/httpsig/dist/utils/signature.js var require_signature = __commonJS({ - "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/signature.js"(exports) { + "node_modules/@hellocoop/httpsig/dist/utils/signature.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.generateSignatureBase = generateSignatureBase; exports.generateSignatureInputHeader = generateSignatureInputHeader; + exports.generateSignatureParams = generateSignatureParams; exports.generateSignatureKeyHeader = generateSignatureKeyHeader; exports.generateSignatureHeader = generateSignatureHeader; exports.generateContentDigest = generateContentDigest; @@ -373,6 +980,18 @@ exports.parseSignature = parseSignature; var base64_js_1 = require_base64(); var errors_js_1 = require_errors(); + var structured_fields_js_1 = require_structured_fields(); + function buildSignatureParams(components, created) { + const items = components.map((component) => [ + component, + /* @__PURE__ */ new Map() + ]); + return [items, /* @__PURE__ */ new Map([["created", created]])]; + } + function parseFailure(field, error) { + const detail = error instanceof Error ? error.message : String(error); + return new Error(`Invalid ${field} format: ${detail}`); + } function generateSignatureBase(components, componentValues) { const lines = []; for (const component of components) { @@ -385,10 +1004,15 @@ return lines.join("\n"); } function generateSignatureInputHeader(label, components, created) { - const componentList = components.map((c) => `"${c}"`).join(" "); - return `${label}=(${componentList});created=${created}`; + return (0, structured_fields_js_1.serializeDictionary)(/* @__PURE__ */ new Map([[label, buildSignatureParams(components, created)]])); + } + function generateSignatureParams(components, created) { + return (0, structured_fields_js_1.serializeInnerList)(buildSignatureParams(components, created)); } function generateSignatureKeyHeader(label, signatureKey, publicJwk2) { + const oneMember = (scheme, params) => (0, structured_fields_js_1.serializeDictionary)(/* @__PURE__ */ new Map([ + [label, [new structured_fields_js_1.Token(scheme), new Map(params)]] + ])); if (signatureKey.type === "hwk") { if (!publicJwk2) { throw new Error("Public JWK required for hwk signature key type"); @@ -397,40 +1021,46 @@ throw new Error("Public JWK missing required alg member for hwk signature key type"); } const params = [ - `alg="${publicJwk2.alg}"`, - `kty="${publicJwk2.kty}"` + ["alg", publicJwk2.alg], + ["kty", publicJwk2.kty] ]; if (publicJwk2.crv) - params.push(`crv="${publicJwk2.crv}"`); + params.push(["crv", publicJwk2.crv]); if (publicJwk2.x) - params.push(`x="${publicJwk2.x}"`); + params.push(["x", publicJwk2.x]); if (publicJwk2.y) - params.push(`y="${publicJwk2.y}"`); + params.push(["y", publicJwk2.y]); if (publicJwk2.n) - params.push(`n="${publicJwk2.n}"`); + params.push(["n", publicJwk2.n]); if (publicJwk2.e) - params.push(`e="${publicJwk2.e}"`); - return `${label}=hwk;${params.join(";")}`; + params.push(["e", publicJwk2.e]); + return oneMember("hwk", params); } if (signatureKey.type === "jwt") { - return `${label}=jwt;jwt="${signatureKey.jwt}"`; + return oneMember("jwt", [["jwt", signatureKey.jwt]]); } if (signatureKey.type === "jkt_jwt") { - return `${label}=jkt-jwt;jwt="${signatureKey.jwt}"`; + return oneMember("jkt-jwt", [["jwt", signatureKey.jwt]]); } if (signatureKey.type === "jwks_uri") { - const params = [ - `id="${signatureKey.id}"`, - `dwk="${signatureKey.dwk}"`, - `kid="${signatureKey.kid}"` - ]; - return `${label}=jwks_uri;${params.join(";")}`; + return oneMember("jwks_uri", [ + ["id", signatureKey.id], + ["dwk", signatureKey.dwk], + ["kid", signatureKey.kid] + ]); } throw new Error(`Unsupported signature key type: ${signatureKey.type}`); } function generateSignatureHeader(label, signature) { - const encoded = (0, base64_js_1.base64Encode)(signature); - return `${label}=:${encoded}:`; + return (0, structured_fields_js_1.serializeDictionary)(/* @__PURE__ */ new Map([ + [ + label, + [ + new structured_fields_js_1.ByteSequence((0, base64_js_1.base64Encode)(signature)), + /* @__PURE__ */ new Map() + ] + ] + ])); } async function generateContentDigest(body) { let bytes; @@ -443,65 +1073,73 @@ } else if (Buffer.isBuffer(body)) { bytes = new Uint8Array(body); } else { - bytes = new TextEncoder().encode(String(body)); + throw new Error(`Cannot generate content-digest for body type: ${body?.constructor?.name ?? typeof body}`); } const hash = await (0, base64_js_1.sha256)(bytes); const encoded = (0, base64_js_1.base64Encode)(hash); return `sha-256=:${encoded}:`; } function parseSignatureInput(header) { + let dictionary; + try { + dictionary = (0, structured_fields_js_1.parseDictionary)(header); + } catch (error) { + throw parseFailure("Signature-Input", error); + } const results = []; - const parts = header.split(",").map((p) => p.trim()); - for (const part of parts) { - const match = part.match(/^([^=]+)=\(([^)]*)\);(.+)$/); - if (!match) { - throw new Error(`Invalid Signature-Input format: ${part}`); - } - const label = match[1].trim(); - const componentsStr = match[2]; - const paramsStr = match[3]; - const components = componentsStr.split(/\s+/).map((c) => c.replace(/"/g, "")).filter((c) => c); - const params = {}; - const paramPairs = paramsStr.split(";").map((p) => p.trim()); - for (const pair of paramPairs) { - const [key, value] = pair.split("=").map((s) => s.trim()); - if (key === "created") { - params.created = parseInt(value, 10); - } else { - params[key] = value; + for (const [label, member] of dictionary) { + if (!(0, structured_fields_js_1.isInnerList)(member)) { + throw new Error(`Invalid Signature-Input format: member "${label}" is not an Inner List of covered components`); + } + const [items, parameters] = member; + const components = []; + for (const [bareItem, itemParameters] of items) { + if (typeof bareItem !== "string") { + throw new Error("Invalid Signature-Input format: a covered component identifier must be a String"); + } + if (itemParameters.size > 0) { + throw new Error(`Unsupported component parameters on "${bareItem}" in Signature-Input`); } + components.push(bareItem); + } + const params = {}; + for (const [key, value] of parameters) { + params[key] = value; } - if (!params.created) { + if (typeof params.created !== "number") { throw new Error("Signature-Input missing required parameter: created"); } - results.push({ label, components, params }); + results.push({ + label, + components, + params, + signatureParams: member + }); } return results; } function parseSignatureKey(header) { - const trimmed = header.trim(); - let inQuote = false; - for (let i = 0; i < trimmed.length; i++) { - if (trimmed[i] === '"' && (i === 0 || trimmed[i - 1] !== "\\")) { - inQuote = !inQuote; - } else if (trimmed[i] === "," && !inQuote) { - throw new Error("Invalid Signature-Key: must have exactly one dictionary member"); - } - } - const match = trimmed.match(/^([\w-]+)=([\w-]+)(.*)$/); - if (!match) { - throw new Error("Invalid Signature-Key: must be RFC 8941 Dictionary with format label=scheme;params"); - } - const label = match[1]; - const scheme = match[2]; - const paramsStr = match[3]; + const malformed = new Error("Invalid Signature-Key: must be RFC 8941 Dictionary with format label=scheme;params"); + let dictionary; + try { + dictionary = (0, structured_fields_js_1.parseDictionary)(header); + } catch { + throw malformed; + } + if (dictionary.size !== 1) { + throw new Error("Invalid Signature-Key: must have exactly one dictionary member"); + } + const [label, member] = [...dictionary][0]; + if ((0, structured_fields_js_1.isInnerList)(member) || !(member[0] instanceof structured_fields_js_1.Token)) { + throw malformed; + } + const scheme = member[0].toString(); const params = {}; - if (paramsStr) { - const paramMatches = paramsStr.matchAll(/;([\w-]+)=(?:"([^"]*)"|(\w+))/g); - for (const paramMatch of paramMatches) { - const key = paramMatch[1]; - const value = paramMatch[2] !== void 0 ? paramMatch[2] : paramMatch[3]; - params[key] = value; + for (const [key, value] of member[1]) { + try { + params[key] = (0, structured_fields_js_1.bareItemToString)(value); + } catch { + throw (0, errors_js_1.invalidKey)(`Signature-Key ${key} parameter must be a String or Token`); } } if (!["hwk", "jwt", "jkt-jwt", "jwks_uri"].includes(scheme)) { @@ -562,20 +1200,29 @@ throw (0, errors_js_1.unsupportedScheme)(`Unsupported Signature-Key scheme: ${scheme}`); } function generateSignatureErrorHeader(signatureError) { - const parts = [`error=${signatureError.error}`]; + const dictionary = /* @__PURE__ */ new Map([ + ["error", [new structured_fields_js_1.Token(signatureError.error), /* @__PURE__ */ new Map()]] + ]); if (signatureError.required_input) { - const inputList = signatureError.required_input.map((c) => `"${c}"`).join(" "); - parts.push(`required_input=(${inputList})`); + dictionary.set("required_input", [ + signatureError.required_input.map((c) => [c, /* @__PURE__ */ new Map()]), + /* @__PURE__ */ new Map() + ]); } - return parts.join(", "); + return (0, structured_fields_js_1.serializeDictionary)(dictionary); } function parseSignatureError(header) { - const trimmed = header.trim(); - const errorMatch = trimmed.match(/error=([\w]+)/); - if (!errorMatch) { + let dictionary; + try { + dictionary = (0, structured_fields_js_1.parseDictionary)(header); + } catch (error2) { + throw parseFailure("Signature-Error", error2); + } + const errorMember = dictionary.get("error"); + if (errorMember === void 0 || (0, structured_fields_js_1.isInnerList)(errorMember)) { throw new Error("Invalid Signature-Error: missing error member"); } - const error = errorMatch[1]; + const error = (0, structured_fields_js_1.bareItemToString)(errorMember[0]); const validCodes = [ "unsupported_algorithm", "unsupported_scheme", @@ -593,22 +1240,25 @@ throw new Error(`Invalid Signature-Error code: ${error}`); } const result = { error }; - const inputMatch = trimmed.match(/required_input=\(([^)]*)\)/); - if (inputMatch) { - result.required_input = inputMatch[1].split(/\s+/).map((c) => c.replace(/"/g, "")).filter((c) => c); + const inputMember = dictionary.get("required_input"); + if (inputMember !== void 0) { + if (!(0, structured_fields_js_1.isInnerList)(inputMember)) { + throw new Error("Invalid Signature-Error: required_input must be an Inner List"); + } + result.required_input = inputMember[0].map(([component]) => (0, structured_fields_js_1.bareItemToString)(component)); } return result; } function generateTokenList(values) { for (const value of values) { - if (!/^[A-Za-z*][A-Za-z0-9!#$%&'*+\-.^_`|~:/]*$/.test(value)) { + if (!(0, structured_fields_js_1.isValidTokenStr)(value)) { throw new Error(`Value is not a valid Structured Field Token: ${value}`); } } - return values.join(", "); + return (0, structured_fields_js_1.serializeList)(values.map((value) => [new structured_fields_js_1.Token(value), /* @__PURE__ */ new Map()])); } function parseTokenList(header) { - return header.split(",").map((v) => v.trim()).filter((v) => /^[A-Za-z*][A-Za-z0-9!#$%&'*+\-.^_`|~:/]*$/.test(v)); + return (0, structured_fields_js_1.parseList)(header).filter((member) => !(0, structured_fields_js_1.isInnerList)(member)).map(([bareItem]) => bareItem).filter((bareItem) => bareItem instanceof structured_fields_js_1.Token).map((token) => token.toString()); } function generateAcceptSignatureSchemeHeader(schemes) { return generateTokenList(schemes); @@ -624,61 +1274,70 @@ } function generateAcceptSignatureHeader(params) { const { label = "sig", components, alg, tag } = params; - const componentList = components.map((c) => `"${c}"`).join(" "); - let header = `${label}=(${componentList})`; + const parameters = /* @__PURE__ */ new Map(); if (alg) { - header += `;alg="${alg}"`; + parameters.set("alg", alg); } if (tag) { - header += `;tag="${tag}"`; + parameters.set("tag", tag); } - return header; + const innerList = [ + components.map((c) => [c, /* @__PURE__ */ new Map()]), + parameters + ]; + return (0, structured_fields_js_1.serializeDictionary)(/* @__PURE__ */ new Map([[label, innerList]])); } function parseAcceptSignature(header) { - const trimmed = header.trim(); - const match = trimmed.match(/^([\w-]+)=\(([^)]*)\)(.*)$/); - if (!match) { - throw new Error("Invalid Accept-Signature format"); - } - const label = match[1]; - const componentsStr = match[2]; - const paramsStr = match[3]; - const components = componentsStr.split(/\s+/).map((c) => c.replace(/"/g, "")).filter((c) => c); - const result = { label, components }; - if (paramsStr) { - const algMatch = paramsStr.match(/;alg="([^"]*)"/); - if (algMatch) { - result.alg = algMatch[1]; - } - const tagMatch = paramsStr.match(/;tag="([^"]*)"/); - if (tagMatch) { - result.tag = tagMatch[1]; - } + const malformed = new Error("Invalid Accept-Signature format"); + let dictionary; + try { + dictionary = (0, structured_fields_js_1.parseDictionary)(header); + } catch { + throw malformed; + } + if (dictionary.size !== 1) { + throw malformed; + } + const [label, member] = [...dictionary][0]; + if (!(0, structured_fields_js_1.isInnerList)(member)) { + throw malformed; + } + const result = { + label, + components: member[0].map(([component]) => (0, structured_fields_js_1.bareItemToString)(component)) + }; + const alg = member[1].get("alg"); + if (alg !== void 0) { + result.alg = (0, structured_fields_js_1.bareItemToString)(alg); + } + const tag = member[1].get("tag"); + if (tag !== void 0) { + result.tag = (0, structured_fields_js_1.bareItemToString)(tag); } return result; } function parseSignature(header) { + let dictionary; + try { + dictionary = (0, structured_fields_js_1.parseDictionary)(header); + } catch (error) { + throw parseFailure("Signature", error); + } const results = /* @__PURE__ */ new Map(); - const entries = header.split(/,(?=\s*\w+=)/); - for (const entry of entries) { - const trimmed = entry.trim(); - const match = trimmed.match(/^([^=]+)=:([^:]+):$/); - if (!match) { - throw new Error(`Invalid Signature format: ${trimmed}`); - } - const label = match[1].trim(); - const base64 = match[2]; - const signature = Buffer.from(base64, "base64"); - results.set(label, new Uint8Array(signature)); + for (const [label, member] of dictionary) { + if ((0, structured_fields_js_1.isInnerList)(member) || !(0, structured_fields_js_1.isByteSequence)(member[0])) { + throw new Error(`Invalid Signature format: member "${label}" is not a Byte Sequence`); + } + results.set(label, (0, base64_js_1.base64Decode)(member[0].toBase64())); } return results; } } }); - // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/fetch.js + // node_modules/@hellocoop/httpsig/dist/fetch.js var require_fetch = __commonJS({ - "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/fetch.js"(exports) { + "node_modules/@hellocoop/httpsig/dist/fetch.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.fetch = fetch2; @@ -703,6 +1362,9 @@ } return "application/octet-stream"; } + function isDigestibleBody(body) { + return typeof body === "string" || body instanceof Uint8Array || body instanceof ArrayBuffer || Buffer.isBuffer(body); + } function validateComponents(components, headers) { for (const component of components) { if (component === "@signature-params" || component === "signature-key" || component === "signature-input" || component === "signature") { @@ -720,7 +1382,7 @@ } } async function fetch2(url, options) { - const { signingKey, signingCryptoKey, signatureKey, label = "sig", components: customComponents, dryRun = false, returnSent = false, method = "GET", headers: inputHeaders = {}, body, ...fetchOptions } = options; + const { signingKey, signingCryptoKey, signatureKey, label = "sig", components: customComponents, contentDigest = "auto", dryRun = false, returnSent = false, method = "GET", headers: inputHeaders = {}, body, ...fetchOptions } = options; (0, crypto_js_1.validateJwk)(signingKey); let privateKey; let algorithm; @@ -745,6 +1407,15 @@ const hasBody = body !== void 0 && body !== null; components = hasBody ? [...types_js_1.DEFAULT_COMPONENTS_BODY] : [...types_js_1.DEFAULT_COMPONENTS_GET]; } + if (body !== void 0 && body !== null && contentDigest !== "omit") { + const digestible = isDigestibleBody(body); + if (!digestible && contentDigest === "require") { + throw new Error('contentDigest is "require" but the body cannot be digested: only string, Uint8Array, ArrayBuffer, and Buffer bodies have their exact bytes available to hash'); + } + if (digestible && !components.includes("content-digest")) { + components.push("content-digest"); + } + } const componentValues = /* @__PURE__ */ new Map(); if (body !== void 0 && body !== null) { if (!headers.has("content-type")) { @@ -754,8 +1425,8 @@ } } if (components.includes("content-digest")) { - const contentDigest = await (0, signature_js_1.generateContentDigest)(body); - headers.set("content-digest", contentDigest); + const contentDigest2 = await (0, signature_js_1.generateContentDigest)(body); + headers.set("content-digest", contentDigest2); } } if (components.includes("signature-key")) { @@ -800,9 +1471,7 @@ const created = Math.floor(Date.now() / 1e3); const signatureInputHeader = (0, signature_js_1.generateSignatureInputHeader)(label, components, created); headers.set("signature-input", signatureInputHeader); - const componentList = components.map((c) => `"${c}"`).join(" "); - const signatureParams = `(${componentList});created=${created}`; - componentValues.set("@signature-params", signatureParams); + componentValues.set("@signature-params", (0, signature_js_1.generateSignatureParams)(components, created)); components.push("@signature-params"); const signatureBase = (0, signature_js_1.generateSignatureBase)(components, componentValues); const signatureBaseBytes = new TextEncoder().encode(signatureBase); @@ -834,9 +1503,9 @@ } }); - // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/thumbprint.js + // node_modules/@hellocoop/httpsig/dist/utils/thumbprint.js var require_thumbprint = __commonJS({ - "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/thumbprint.js"(exports) { + "node_modules/@hellocoop/httpsig/dist/utils/thumbprint.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.calculateThumbprint = calculateThumbprint; @@ -888,9 +1557,9 @@ } }); - // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/cache.js + // node_modules/@hellocoop/httpsig/dist/utils/cache.js var require_cache = __commonJS({ - "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/utils/cache.js"(exports) { + "node_modules/@hellocoop/httpsig/dist/utils/cache.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.BoundedTtlCache = exports.DEFAULT_MAX_ENTRIES = void 0; @@ -953,9 +1622,9 @@ } }); - // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/verify.js + // node_modules/@hellocoop/httpsig/dist/verify.js var require_verify = __commonJS({ - "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/verify.js"(exports) { + "node_modules/@hellocoop/httpsig/dist/verify.js"(exports) { "use strict"; var __createBinding = exports && exports.__createBinding || (Object.create ? (function(o, m, k, k2) { if (k2 === void 0) k2 = k; @@ -998,6 +1667,7 @@ exports.verify = verify; var crypto_js_1 = require_crypto(); var signature_js_1 = require_signature(); + var structured_fields_js_1 = require_structured_fields(); var base64_js_1 = require_base64(); var thumbprint_js_1 = require_thumbprint(); var cache_js_1 = require_cache(); @@ -1204,7 +1874,8 @@ maxClockSkew = 60, jwksCacheTtl = 36e5, // 1 hour - supportedAlgorithms + supportedAlgorithms, + requireContentDigest = false } = options; const accepted = supportedAlgorithms ?? crypto_js_1.SUPPORTED_ALGORITHMS; try { @@ -1296,6 +1967,16 @@ componentValues.set("@request-target", `${request.path}${queryString}`); componentValues.set("@path", request.path); componentValues.set("@query", request.query || ""); + if (requireContentDigest && request.body !== void 0 && !components.includes("content-digest")) { + throw (0, errors_js_1.invalidInput)("content-digest must be a covered component on a request with a body", [ + "@method", + "@authority", + "@path", + "content-digest", + "content-type", + "signature-key" + ]); + } if (request.body !== void 0 && components.includes("content-digest")) { const expectedDigest = headers.get("content-digest"); if (!expectedDigest) { @@ -1317,18 +1998,7 @@ } componentValues.set(component, value); } - const componentList = components.map((c) => `"${c}"`).join(" "); - const paramPairs = Object.entries(params).map(([key, value]) => { - if (typeof value === "number") { - return `${key}=${value}`; - } - const stringValue = String(value); - if (stringValue.startsWith('"') && stringValue.endsWith('"')) { - return `${key}=${stringValue}`; - } - return `${key}="${stringValue}"`; - }).join(";"); - const signatureParams = `(${componentList});${paramPairs}`; + const signatureParams = (0, structured_fields_js_1.serializeInnerList)(signatureInput.signatureParams); componentValues.set("@signature-params", signatureParams); const componentsWithParams = [...components, "@signature-params"]; const signatureBase = (0, signature_js_1.generateSignatureBase)(componentsWithParams, componentValues); @@ -1373,9 +2043,9 @@ } }); - // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/helpers.js + // node_modules/@hellocoop/httpsig/dist/helpers.js var require_helpers = __commonJS({ - "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/helpers.js"(exports) { + "node_modules/@hellocoop/httpsig/dist/helpers.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.expressVerify = expressVerify; @@ -1431,12 +2101,12 @@ } }); - // ../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/index.js + // node_modules/@hellocoop/httpsig/dist/index.js var require_dist = __commonJS({ - "../../aauth-dev/registry/node_modules/@hellocoop/httpsig/dist/index.js"(exports) { + "node_modules/@hellocoop/httpsig/dist/index.js"(exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); - exports.DEFAULT_COMPONENTS_BODY = exports.DEFAULT_COMPONENTS_GET = exports.VALID_DERIVED_COMPONENTS = exports.calculateThumbprint = exports.SignatureVerificationError = exports.SUPPORTED_ALGORITHMS = exports.determineAlgorithm = exports.generateKeyPair = exports.parseAcceptSignatureAlg = exports.generateAcceptSignatureAlgHeader = exports.parseAcceptSignatureScheme = exports.generateAcceptSignatureSchemeHeader = exports.parseAcceptSignature = exports.generateAcceptSignatureHeader = exports.parseSignatureError = exports.generateSignatureErrorHeader = exports.nextJsPagesVerify = exports.nextJsVerify = exports.fastifyVerify = exports.expressVerify = exports.verify = void 0; + exports.DEFAULT_COMPONENTS_BODY = exports.DEFAULT_COMPONENTS_GET = exports.VALID_DERIVED_COMPONENTS = exports.calculateThumbprint = exports.SignatureVerificationError = exports.SUPPORTED_ALGORITHMS = exports.determineAlgorithm = exports.generateKeyPair = exports.SerializeError = exports.ParseError = exports.ByteSequence = exports.Token = exports.isValidKeyStr = exports.isValidTokenStr = exports.isByteSequence = exports.isInnerList = exports.bareItemToString = exports.serializeParameters = exports.serializeBareItem = exports.serializeInnerList = exports.serializeItem = exports.serializeList = exports.serializeDictionary = exports.parseItem = exports.parseList = exports.parseDictionary = exports.parseAcceptSignatureAlg = exports.generateAcceptSignatureAlgHeader = exports.parseAcceptSignatureScheme = exports.generateAcceptSignatureSchemeHeader = exports.parseAcceptSignature = exports.generateAcceptSignatureHeader = exports.parseSignatureError = exports.generateSignatureErrorHeader = exports.nextJsPagesVerify = exports.nextJsVerify = exports.fastifyVerify = exports.expressVerify = exports.verify = void 0; var fetch_js_1 = require_fetch(); Object.defineProperty(exports, "fetch", { enumerable: true, get: function() { return fetch_js_1.fetch; @@ -1483,6 +2153,61 @@ Object.defineProperty(exports, "parseAcceptSignatureAlg", { enumerable: true, get: function() { return signature_js_1.parseAcceptSignatureAlg; } }); + var structured_fields_js_1 = require_structured_fields(); + Object.defineProperty(exports, "parseDictionary", { enumerable: true, get: function() { + return structured_fields_js_1.parseDictionary; + } }); + Object.defineProperty(exports, "parseList", { enumerable: true, get: function() { + return structured_fields_js_1.parseList; + } }); + Object.defineProperty(exports, "parseItem", { enumerable: true, get: function() { + return structured_fields_js_1.parseItem; + } }); + Object.defineProperty(exports, "serializeDictionary", { enumerable: true, get: function() { + return structured_fields_js_1.serializeDictionary; + } }); + Object.defineProperty(exports, "serializeList", { enumerable: true, get: function() { + return structured_fields_js_1.serializeList; + } }); + Object.defineProperty(exports, "serializeItem", { enumerable: true, get: function() { + return structured_fields_js_1.serializeItem; + } }); + Object.defineProperty(exports, "serializeInnerList", { enumerable: true, get: function() { + return structured_fields_js_1.serializeInnerList; + } }); + Object.defineProperty(exports, "serializeBareItem", { enumerable: true, get: function() { + return structured_fields_js_1.serializeBareItem; + } }); + Object.defineProperty(exports, "serializeParameters", { enumerable: true, get: function() { + return structured_fields_js_1.serializeParameters; + } }); + Object.defineProperty(exports, "bareItemToString", { enumerable: true, get: function() { + return structured_fields_js_1.bareItemToString; + } }); + Object.defineProperty(exports, "isInnerList", { enumerable: true, get: function() { + return structured_fields_js_1.isInnerList; + } }); + Object.defineProperty(exports, "isByteSequence", { enumerable: true, get: function() { + return structured_fields_js_1.isByteSequence; + } }); + Object.defineProperty(exports, "isValidTokenStr", { enumerable: true, get: function() { + return structured_fields_js_1.isValidTokenStr; + } }); + Object.defineProperty(exports, "isValidKeyStr", { enumerable: true, get: function() { + return structured_fields_js_1.isValidKeyStr; + } }); + Object.defineProperty(exports, "Token", { enumerable: true, get: function() { + return structured_fields_js_1.Token; + } }); + Object.defineProperty(exports, "ByteSequence", { enumerable: true, get: function() { + return structured_fields_js_1.ByteSequence; + } }); + Object.defineProperty(exports, "ParseError", { enumerable: true, get: function() { + return structured_fields_js_1.ParseError; + } }); + Object.defineProperty(exports, "SerializeError", { enumerable: true, get: function() { + return structured_fields_js_1.SerializeError; + } }); var crypto_js_1 = require_crypto(); Object.defineProperty(exports, "generateKeyPair", { enumerable: true, get: function() { return crypto_js_1.generateKeyPair; @@ -1572,15 +2297,13 @@ const kp = await getKeyPair(); const pub = await publicJwk(kp); const hasBody = body != null; - const components = hasBody ? ["@method", "@authority", "@path", "content-type", "signature-key"] : ["@method", "@authority", "@path", "signature-key"]; return (0, import_httpsig.fetch)(url, { method, headers: hasBody ? { "Content-Type": "application/json", ...headers } : headers, body: hasBody ? body : void 0, signingKey: pub, signingCryptoKey: kp.privateKey, - signatureKey: { type: "jwt", jwt }, - components + signatureKey: { type: "jwt", jwt } }); } async function bootstrap() { @@ -1592,8 +2315,7 @@ body: JSON.stringify({ ps: PS_DEFAULT }), signingKey: pub, signingCryptoKey: kp.privateKey, - signatureKey: { type: "hwk" }, - components: ["@method", "@authority", "@path", "content-type", "signature-key"] + signatureKey: { type: "hwk" } }); const data = await response.json(); if (!response.ok || !data.agent_token) throw new Error(data.error || "bootstrap failed"); diff --git a/src/agent-token.ts b/src/agent-token.ts index 2dd78a8..68cef58 100644 --- a/src/agent-token.ts +++ b/src/agent-token.ts @@ -59,9 +59,14 @@ export async function verifyAgentToken( ...(rawBody !== undefined ? { body: rawBody } : {}), }) + // The registry is a resource, so it does not REQUIRE content-digest + // coverage (AAuth §10.3 binds PS and AS endpoints; resources declare + // needs instead). For a body-carrying request the challenge still asks + // for content-digest + content-type — what an httpsig 2.2 client signs + // by default — while verification accepts signatures without them. const components = rawBody !== undefined - ? ['@method', '@authority', '@path', 'content-type', 'signature-key'] + ? ['@method', '@authority', '@path', 'content-digest', 'content-type', 'signature-key'] : ['@method', '@authority', '@path', 'signature-key'] if (!sigResult.verified) { diff --git a/src/ap.ts b/src/ap.ts index a0739cc..ddf7476 100644 --- a/src/ap.ts +++ b/src/ap.ts @@ -40,14 +40,20 @@ export interface SigHwkResult { export async function verifySigHwk(c: Context): Promise { const rawBody = await c.req.text() const url = new URL(c.req.url) - const sigResult = await httpSigVerify({ - method: c.req.method, - authority: url.host, - path: url.pathname, - query: url.search.replace(/^\?/, ''), - headers: c.req.raw.headers, - body: rawBody, - }) + // /bootstrap mints agent tokens, so it enforces the AAuth HTTPSig profile + // (§10.3): a body-carrying request MUST cover content-digest. The + // /resources API is the resource role and stays exempt (agent-token.ts). + const sigResult = await httpSigVerify( + { + method: c.req.method, + authority: url.host, + path: url.pathname, + query: url.search.replace(/^\?/, ''), + headers: c.req.raw.headers, + body: rawBody, + }, + { requireContentDigest: true }, + ) if (!sigResult.verified) { return c.json({ error: `signature verification failed: ${sigResult.error || 'unknown'}` }, 401) as unknown as Response }