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..1a78846 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' @@ -76,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, @@ -91,7 +99,6 @@ async function signedFetch(url, { method = 'GET', body, jwt, headers = {} } = {} signingKey: pub, signingCryptoKey: kp.privateKey, signatureKey: { type: 'jwt', jwt }, - components, }) } @@ -99,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' }, @@ -106,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') @@ -160,59 +169,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 +279,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 +291,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 +343,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 +380,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/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 acec111..cbf84ec 100644 --- a/public/registry.js +++ b/public/registry.js @@ -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; @@ -348,6 +350,612 @@ } }); + // 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({ "node_modules/@hellocoop/httpsig/dist/utils/signature.js"(exports) { @@ -355,6 +963,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.generateSignatureBase = generateSignatureBase; exports.generateSignatureInputHeader = generateSignatureInputHeader; + exports.generateSignatureParams = generateSignatureParams; exports.generateSignatureKeyHeader = generateSignatureKeyHeader; exports.generateSignatureHeader = generateSignatureHeader; exports.generateContentDigest = generateContentDigest; @@ -371,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) { @@ -383,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"); @@ -395,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; @@ -441,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)) { @@ -560,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", @@ -591,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); @@ -622,52 +1274,61 @@ } 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; } @@ -701,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") { @@ -718,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; @@ -743,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")) { @@ -752,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")) { @@ -798,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); @@ -996,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(); @@ -1202,7 +1874,8 @@ maxClockSkew = 60, jwksCacheTtl = 36e5, // 1 hour - supportedAlgorithms + supportedAlgorithms, + requireContentDigest = false } = options; const accepted = supportedAlgorithms ?? crypto_js_1.SUPPORTED_ALGORITHMS; try { @@ -1294,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) { @@ -1315,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); @@ -1434,7 +2106,7 @@ "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; @@ -1481,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; @@ -1570,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() { @@ -1590,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"); @@ -1630,46 +2354,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 +2446,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 +2486,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 +2506,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/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 f2648f7..ddf7476 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 } @@ -33,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 } @@ -91,7 +104,9 @@ export async function mintAgentToken( const publicJwk = await getPublicJWK(env.SIGNING_KEY) const now = Math.floor(Date.now() / 1000) - const header = { alg: 'Ed25519', 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 3644de3..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,11 +107,18 @@ 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' }, - // RFC 9864 fully-specified identifier; what we mint, and what peers on - // signature-key -08 conventions mint instead of the polymorphic EdDSA. Ed25519: { importAlgo: { name: 'Ed25519' }, verifyAlgo: 'Ed25519' }, RS256: { importAlgo: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, @@ -121,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 dcf6c3a..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: 'Ed25519', 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 a0b8e6e..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,