From 3f82ef56ae64550576c4767a7ec637a041fedd70 Mon Sep 17 00:00:00 2001 From: dickhardt Date: Fri, 25 Sep 2026 22:44:57 +0100 Subject: [PATCH 1/3] @aauth/call-log 0.1.0: the aauth.call record, at each end One log record per HTTP call between AAuth roles. A pure builder (tokens as type + payload, the signer from Signature-Key, the three AAuth headers parsed, the 30 KB cap), a Hono-shaped middleware for the callee side, and logged fetch wrappers for the caller side (@aauth/agent's onSigned, or @hellocoop/httpsig's returnSent). `parent` rides in AsyncLocalStorage from the middleware to any logged fetch in its continuation. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SdTYzN8NvzehCVCSqhadH5 --- .github/workflows/release.yml | 4 +- call-log/README.md | 87 +++++++++ call-log/package.json | 38 ++++ call-log/src/callee.ts | 100 ++++++++++ call-log/src/caller.ts | 159 ++++++++++++++++ call-log/src/context.ts | 35 ++++ call-log/src/host.ts | 44 +++++ call-log/src/index.ts | 19 ++ call-log/src/record.test.ts | 112 +++++++++++ call-log/src/record.ts | 346 ++++++++++++++++++++++++++++++++++ call-log/src/sides.test.ts | 160 ++++++++++++++++ call-log/tsconfig.json | 17 ++ package-lock.json | 16 +- package.json | 3 +- vitest.config.ts | 1 + 15 files changed, 1137 insertions(+), 4 deletions(-) create mode 100644 call-log/README.md create mode 100644 call-log/package.json create mode 100644 call-log/src/callee.ts create mode 100644 call-log/src/caller.ts create mode 100644 call-log/src/context.ts create mode 100644 call-log/src/host.ts create mode 100644 call-log/src/index.ts create mode 100644 call-log/src/record.test.ts create mode 100644 call-log/src/record.ts create mode 100644 call-log/src/sides.test.ts create mode 100644 call-log/tsconfig.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20fab8d..f88bfd4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,7 +78,7 @@ jobs: - run: npm ci - name: Build packages (dependency order) run: | - for pkg in protocol interaction-code local-keys agent resource bootstrap fetch mcp-openclaw mcp-stdio; do + for pkg in protocol interaction-code call-log local-keys agent resource bootstrap fetch mcp-openclaw mcp-stdio; do echo "Building $pkg..." (cd "$pkg" && npm run build) done @@ -88,7 +88,7 @@ jobs: # all-must-match gate. - name: Publish changed packages with provenance run: | - for pkg in protocol interaction-code local-keys agent resource bootstrap fetch mcp-openclaw mcp-stdio; do + for pkg in protocol interaction-code call-log local-keys agent resource bootstrap fetch mcp-openclaw mcp-stdio; do version=$(node -p "require('./$pkg/package.json').version") published=$(npm view "@aauth/$pkg" version 2>/dev/null || echo "0.0.0") if [ "$version" = "$published" ]; then diff --git a/call-log/README.md b/call-log/README.md new file mode 100644 index 0000000..c1cdb9a --- /dev/null +++ b/call-log/README.md @@ -0,0 +1,87 @@ +# @aauth/call-log + +One log record per HTTP call between AAuth roles, written at each end. The +AAuth call log (`monitor.aauth.dev`) joins the caller's and the callee's record +on `call_id` and shows one row: who called whom, the request, the response. + +The record is `aauth.call`, specified in `aauth-dev/monitor` +`plan/CALL_RECORD.md`. No dependencies. Runs in Cloudflare Workers +(`nodejs_compat`) and Node. + +## What it does + +- **Tokens are logged as `{ type, payload }`** — the JWT's `typ` header and its + claims — wherever they appear: in `signed` and in place of the JWT string in + a body, at any depth. Never the JWT, so no log holds a presentable token. +- **Bodies are logged.** JSON bodies as values; anything else as its content + type and size. A record over 30 KB has its larger body cut to text and + `truncated: true`. +- **`call_id`** is base64url SHA-256 of the `Signature` header. Both ends hold + it, so their records join with no new header on the wire. +- **`parent`** is the call being handled when an outbound call is made. It is + carried in AsyncLocalStorage from the middleware to any logged fetch in its + async continuation, so call sites pass nothing. Where the chain is broken on + purpose — a queue consumer, an alarm — pass `parent` yourself. +- **Levels:** 30; 40 for a 4xx that is not a challenge, a peer's 5xx, or a + fetch that threw; 50 only for a 5xx the logging party answered itself. + +## The host + +```ts +import { callLogMiddleware, loggedFetch, nameAgent, type CallLogHost } from '@aauth/call-log' + +const host = (c: Context): CallLogHost => ({ + origin: c.env.ORIGIN, // this party's server identifier + role: 'resource', // agent | resource | ps | as + log: (record) => emit(c, record), // your event sink: adds service, timestamp, event_id + defer: (p) => c.executionCtx.waitUntil(p), +}) +``` + +`log` is called once per record, never awaited, wrapped in try/catch. Bodies +are read from clones inside `defer`, after the response has gone. + +## The callee side + +```ts +app.use('*', async (c, next) => callLogMiddleware(host(c))(c, next)) +``` + +One record per request, skipping `OPTIONS`, `HEAD`, `/.well-known/*`, +`/health` and `/openapi.json` (`skip` overrides). The caller is named from +`Signature-Key` without verification, so a refused call still says who +called. A person token names no agent; when your verifier resolves one, say so: + +```ts +nameAgent(verified.agent_id) +``` + +## The caller side + +With `@aauth/agent`'s `createSignedFetch`, which reports the on-wire request +through `onSigned`: + +```ts +const psFetch = loggedFetch( + (onSigned) => createSignedFetch(keyMaterial, { signBody: true, onSigned }), + host(c), + { to_role: 'ps' }, +) +``` + +`makeFetch` is called once per call, so concurrent calls never swap reports. +With `@hellocoop/httpsig`'s `fetch`: + +```ts +const send = loggedHttpsigFetch(httpsigFetch, host(c), { to_role: 'as' }) +const res = await send(url, { method: 'POST', body, signingKey, signatureKey }) +``` + +For a fetch you cannot wrap, `failedFetch(host, { url, status | error })` +records a failure. + +## Pieces + +`callIdOf`, `tokenOf`, `tokenize`, `signerOf`, `paramsOf`, `errorOf`, +`levelOf`, `partOf`, `cap`, `buildRecord`, `targetOf` are exported for a host +that builds records its own way (Wallet's Fastify hooks do). diff --git a/call-log/package.json b/call-log/package.json new file mode 100644 index 0000000..4efb018 --- /dev/null +++ b/call-log/package.json @@ -0,0 +1,38 @@ +{ + "name": "@aauth/call-log", + "version": "0.1.0", + "description": "The aauth.call record: one log record per HTTP call between AAuth roles, at each end. A pure builder, a Hono-shaped middleware for the callee side, and logged fetch wrappers for the caller side.", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "aauth", + "logging", + "observability" + ], + "author": "Dick Hardt ", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/aauth-dev/packages-js", + "directory": "call-log" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } +} diff --git a/call-log/src/callee.ts b/call-log/src/callee.ts new file mode 100644 index 0000000..8f588b0 --- /dev/null +++ b/call-log/src/callee.ts @@ -0,0 +1,100 @@ +// The callee side: a Hono-shaped middleware. It names the call before the +// handler runs (so the handler's outbound calls carry it as `parent`), then +// writes the record once the response is known, reading the cloned bodies +// off the request path. +// +// Typed against the little of Hono it uses, so the package depends on +// nothing: `c.req.raw` (a Request), `c.res` (a Response after `next()`), +// `c.executionCtx.waitUntil` when there is one. + +import { callIdOf, signerOf, paramsOf, errorOf, partOf, buildRecord } from './record.js' +import { runInCall, currentCall, type CallContext } from './context.js' +import { emit, defer, type CallLogHost } from './host.js' + +export interface ContextLike { + req: { raw: Request } + res: Response + executionCtx?: { waitUntil(p: Promise): void } +} +export type Next = () => Promise + +export interface CalleeOptions { + /** Paths (or a test) that are not calls between roles: metadata, health, an SSE stream. */ + skip?: (request: Request) => boolean +} + +const skipByDefault = (request: Request) => { + if (request.method === 'OPTIONS' || request.method === 'HEAD') return true + const path = new URL(request.url).pathname + return path.startsWith('/.well-known/') || path === '/health' || path === '/openapi.json' +} + +// A request body is read once by the handler. Clone before `next()` only +// when it is worth logging: JSON, and small. Cloning tees the stream, and a +// tee that nobody drains holds the bytes. +const worthCloning = (request: Request) => { + if (!request.body) return false + const type = request.headers.get('content-type') ?? '' + if (!/json/i.test(type)) return false + const length = Number(request.headers.get('content-length')) + return !(Number.isFinite(length) && length > 256 * 1024) +} + +/** + * `app.use('*', callLogMiddleware(host))`, before the routes. Every request + * not skipped gets one callee record. + */ +export function callLogMiddleware(host: CallLogHost, options: CalleeOptions = {}) { + const skip = options.skip ?? skipByDefault + return async (c: ContextLike, next: Next): Promise => { + const request = c.req.raw + if (skip(request)) return next() + const started = new Date() + const callId = await callIdOf(request.headers.get('signature')) + const requestClone = worthCloning(request) ? request.clone() : null + const context: CallContext = { callId } + await runInCall(context, next) + const response = c.res + const ended = Date.now() + const responseClone = response.clone() + const hostWithCtx: CallLogHost = c.executionCtx?.waitUntil + ? { ...host, defer: host.defer ?? ((p) => c.executionCtx!.waitUntil(p)) } + : host + defer( + hostWithCtx, + (async () => { + const signer = signerOf(request.headers.get('signature-key')) + const agent = context.agent ?? signer.agent + const params = paramsOf(response.headers) + const url = new URL(request.url) + const requestPart = await partOf(requestClone) + const responsePart = await partOf(responseClone, params) + emit( + hostWithCtx, + buildRecord({ + side: 'callee', + call_id: callId, + from: signer.from ?? agent, + from_role: signer.from_role ?? (agent ? 'agent' : undefined), + to: host.origin, + to_role: host.role, + agent, + method: request.method, + path: url.pathname, + query: url.search.slice(1) || undefined, + status: response.status, + started_at: started.toISOString(), + duration_ms: ended - started.getTime(), + signed: signer.signed, + request: requestPart, + response: responsePart, + error: response.status >= 400 ? errorOf(params, responsePart?.body) : undefined, + }), + ) + })(), + ) + } +} + +/** The call being handled, for a handler that wants to know: its id. */ +export const currentCallId = (): string | undefined => currentCall()?.callId diff --git a/call-log/src/caller.ts b/call-log/src/caller.ts new file mode 100644 index 0000000..b6a022d --- /dev/null +++ b/call-log/src/caller.ts @@ -0,0 +1,159 @@ +// The caller side: a fetch that logs itself. Two shapes of signed fetch +// exist in the fleet, so there are two wrappers over one core: +// +// loggedFetch(makeFetch, host, call) @aauth/agent createSignedFetch, which +// reports the on-wire request through +// `onSigned`. `makeFetch(onSigned)` +// builds one signed fetch per call, so +// concurrent calls cannot swap reports. +// loggedHttpsigFetch(fetch, host, call) @hellocoop/httpsig fetch, called with +// `returnSent: true`. +// +// The record is written once the response body has been read from a clone — +// off the caller's path, through host.defer. `parent` is the call being +// handled (AsyncLocalStorage) unless the caller passes one. + +import { callIdOf, signerOf, paramsOf, errorOf, partOf, buildRecord, tokenize, targetOf, type Role, type Signed } from './record.js' +import { parentFromContext, currentCall } from './context.js' +import { emit, defer, type CallLogHost } from './host.js' + +export interface CallOptions { + /** The callee's role, when the caller knows it. */ + to_role?: Role + /** The agent the call is for, when the caller knows it; else the one being handled. */ + agent?: string + /** The call this one is made inside; else the one being handled. */ + parent?: string +} + +export interface SentLike { + headers: Headers | Record +} + +type Init = RequestInit & { body?: BodyInit | null } + +const headerOf = (h: SentLike['headers'] | undefined, name: string): string | undefined => { + if (!h) return undefined + if (typeof (h as Headers).get === 'function') return (h as Headers).get(name) ?? undefined + const rec = h as Record + return rec[name] ?? rec[name.toLowerCase()] ?? rec[name.replace(/(^|-)([a-z])/g, (m) => m.toUpperCase())] +} + +async function record( + host: CallLogHost, + call: CallOptions, + url: string, + init: Init | undefined, + started: Date, + sent: SentLike | undefined, + outcome: { response: Response; clone: Response } | { error: unknown }, +): Promise { + const parent = call.parent ?? parentFromContext() + const agent = call.agent ?? currentCall()?.agent + const signer: { signed?: Signed } = sent ? signerOf(headerOf(sent.headers, 'signature-key')) : {} + const call_id = await callIdOf(sent ? headerOf(sent.headers, 'signature') : undefined) + const requestBody = typeof init?.body === 'string' ? safeJson(init.body) : undefined + const base = { + side: 'caller' as const, + call_id, + parent, + from: host.origin, + from_role: host.role, + to_role: call.to_role, + agent, + method: (init?.method ?? 'GET').toUpperCase(), + started_at: started.toISOString(), + signed: signer.signed, + request: requestBody === undefined ? undefined : { body: tokenize(requestBody) }, + ...targetOf(url), + } + if ('error' in outcome) { + const err = outcome.error as { name?: string; message?: string } | undefined + emit(host, buildRecord({ ...base, duration_ms: Date.now() - started.getTime(), error: err?.name === 'TimeoutError' || err?.name === 'AbortError' ? 'timeout' : `fetch: ${err?.message ?? String(outcome.error)}` })) + return + } + const { response, clone } = outcome + const duration_ms = Date.now() - started.getTime() + const params = paramsOf(response.headers) + const responsePart = await partOf(clone, params) + emit(host, buildRecord({ ...base, status: response.status, duration_ms, response: responsePart, error: response.status >= 400 ? errorOf(params, responsePart?.body) : undefined })) +} + +function safeJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return text + } +} + +export type FetchLike = (url: string, init?: Init) => Promise + +/** + * For @aauth/agent's createSignedFetch. `makeFetch` is called once per call + * with an `onSigned` to pass into createSignedFetch's options. + */ +export function loggedFetch(makeFetch: (onSigned: (sent: SentLike) => void) => FetchLike, host: CallLogHost, call: CallOptions = {}): FetchLike { + return async (url, init) => { + const started = new Date() + let sent: SentLike | undefined + const inner = makeFetch((s) => { sent = s }) + let response: Response + try { + response = await inner(url, init) + } catch (error) { + defer(host, record(host, call, url, init, started, sent, { error })) + throw error + } + // Clone now, before the caller reads the body; the record reads the clone later. + defer(host, record(host, call, url, init, started, sent, { response, clone: response.clone() })) + return response + } +} + +export interface HttpsigFetchLike { + (url: string, options: Record): Promise +} + +/** + * For @hellocoop/httpsig's fetch: the same options, plus the record. The + * wrapper adds `returnSent: true` and unwraps the result. + */ +export function loggedHttpsigFetch(httpsigFetch: HttpsigFetchLike, host: CallLogHost, call: CallOptions = {}) { + return async (url: string, options: Record): Promise => { + const started = new Date() + const init = options as Init + let result: Awaited> + try { + result = await httpsigFetch(url, { ...options, returnSent: true }) + } catch (error) { + defer(host, record(host, call, url, init, started, undefined, { error })) + throw error + } + const { response, sent } = 'response' in result ? result : { response: result, sent: undefined } + defer(host, record(host, call, url, init, started, sent, { response, clone: response.clone() })) + return response + } +} + +/** A fetch that failed or was refused, when the caller cannot wrap the fetch itself. */ +export function failedFetch(host: CallLogHost, args: { url: string; method?: string; to_role?: Role; started?: Date; status?: number; error?: string; parent?: string }): void { + const started = args.started ?? new Date() + emit( + host, + buildRecord({ + side: 'caller', + call_id: crypto.randomUUID(), + parent: args.parent ?? parentFromContext(), + from: host.origin, + from_role: host.role, + to_role: args.to_role, + method: (args.method ?? 'GET').toUpperCase(), + ...targetOf(args.url), + status: args.status, + started_at: started.toISOString(), + duration_ms: args.started ? Date.now() - args.started.getTime() : undefined, + error: args.error, + }), + ) +} diff --git a/call-log/src/context.ts b/call-log/src/context.ts new file mode 100644 index 0000000..1e11cd8 --- /dev/null +++ b/call-log/src/context.ts @@ -0,0 +1,35 @@ +// The call being handled, for the calls made while handling it: `parent`. +// +// Carried in AsyncLocalStorage (decided 2026-09-25), so a logged fetch made +// anywhere in the async continuation of the middleware finds it without an +// argument. `node:async_hooks` is there under Cloudflare's `nodejs_compat` +// flag and in Node. Where the chain is deliberately broken — a queue +// consumer, an alarm, a promise created outside the request — there is no +// store, and a caller passes `parent` itself. + +import { AsyncLocalStorage } from 'node:async_hooks' + +export interface CallContext { + callId: string + /** The agent this call is for, once a verifier has said (nameAgent). */ + agent?: string +} + +const storage = new AsyncLocalStorage() + +export const runInCall = (context: CallContext, fn: () => T): T => storage.run(context, fn) + +export const currentCall = (): CallContext | undefined => storage.getStore() + +/** The `parent` for a call made now: the call being handled, if any. */ +export const parentFromContext = (): string | undefined => storage.getStore()?.callId + +/** + * A verifier that learned which agent the call is for tells the record (a + * person token names its agent only in the issuer's records). No-op outside + * a logged call. + */ +export function nameAgent(agent: string | null | undefined): void { + const store = storage.getStore() + if (store && typeof agent === 'string' && agent) store.agent = agent +} diff --git a/call-log/src/host.ts b/call-log/src/host.ts new file mode 100644 index 0000000..e80fa6a --- /dev/null +++ b/call-log/src/host.ts @@ -0,0 +1,44 @@ +// What the host supplies: who it is, and where records go. + +import type { CallRecord, Role } from './record.js' + +export interface CallLogHost { + /** This party's server identifier, `https://host`: `to` on the callee side, `from` on the caller side. */ + origin: string + /** This party's role. */ + role: Role + /** + * The sink. Called once per record, never awaited, wrapped in try/catch. + * The host adds its envelope (service, timestamp, event_id) and sends it + * where its other events go — a Cloudflare queue by `ctx.waitUntil`, a + * Pino logger, console. + */ + log: (record: CallRecord) => void | Promise + /** + * Where deferred work goes: reading a cloned body after the response has + * been sent, hashing the Signature. In a Worker, `(p) => ctx.waitUntil(p)`. + * Without one the work is simply not awaited. + */ + defer?: (work: Promise) => void +} + +/** Send a record to the host, never throwing into a request. */ +export function emit(host: CallLogHost, record: CallRecord): void { + try { + const result = host.log(record) + if (result && typeof (result as Promise).catch === 'function') (result as Promise).catch(() => {}) + } catch { + /* logging never fails a request */ + } +} + +export function defer(host: CallLogHost, work: Promise): void { + const guarded = work.catch(() => {}) + if (host.defer) { + try { + host.defer(guarded) + } catch { + /* no execution context: the promise runs on its own */ + } + } +} diff --git a/call-log/src/index.ts b/call-log/src/index.ts new file mode 100644 index 0000000..270bf65 --- /dev/null +++ b/call-log/src/index.ts @@ -0,0 +1,19 @@ +// @aauth/call-log — one log record per HTTP call between AAuth roles, at +// each end. Spec: aauth-dev/monitor plan/CALL_RECORD.md. +// +// callee callLogMiddleware(host) a Hono-shaped middleware +// caller loggedFetch / loggedHttpsigFetch a fetch that logs itself +// parent AsyncLocalStorage: the call being handled, found by any logged +// fetch in its async continuation; nameAgent tells the record +// which agent a verified call is for. + +export type { CallRecord, RecordFields, Role, Side, Token, Signed, Part, Signer } from './record.js' +export { callIdOf, tokenOf, tokenize, signerOf, paramsOf, errorOf, levelOf, partOf, cap, buildRecord, targetOf, MAX_RECORD_BYTES } from './record.js' +export type { CallLogHost } from './host.js' +export { emit } from './host.js' +export type { CallContext } from './context.js' +export { runInCall, currentCall, parentFromContext, nameAgent } from './context.js' +export type { ContextLike, Next, CalleeOptions } from './callee.js' +export { callLogMiddleware, currentCallId } from './callee.js' +export type { CallOptions, SentLike, FetchLike, HttpsigFetchLike } from './caller.js' +export { loggedFetch, loggedHttpsigFetch, failedFetch } from './caller.js' diff --git a/call-log/src/record.test.ts b/call-log/src/record.test.ts new file mode 100644 index 0000000..2bb36d2 --- /dev/null +++ b/call-log/src/record.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest' +import { createHash } from 'node:crypto' +import { callIdOf, tokenOf, tokenize, signerOf, paramsOf, errorOf, levelOf, partOf, cap, buildRecord, targetOf, MAX_RECORD_BYTES } from './index.js' + +const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url') +const jwt = (typ: string, payload: Record) => `${b64({ alg: 'Ed25519', typ })}.${b64(payload)}.c2ln` +const PS = 'https://person.hello-beta.net' +const person = jwt('aa-person+jwt', { iss: PS, aud: 'https://r.example', jti: 'ptk_1', agent_id: 'aauth:a@ap.example' }) + +describe('call_id', () => { + it('is base64url SHA-256 of the Signature header; random without one', async () => { + expect(await callIdOf('sig=:abc:')).toBe(createHash('sha256').update('sig=:abc:').digest('base64url')) + expect(await callIdOf(undefined)).toMatch(/^[0-9a-f-]{36}$/) + }) +}) + +describe('tokens', () => { + it('a JWT is its typ and payload; a JWE and plain strings stay', () => { + expect(tokenOf(person)).toEqual({ type: 'aa-person+jwt', payload: { iss: PS, aud: 'https://r.example', jti: 'ptk_1', agent_id: 'aauth:a@ap.example' } }) + expect(tokenOf('a.b.c.d.e')).toBeNull() + expect(tokenOf('https://r.example/path')).toBeNull() + expect(tokenize({ t: person, list: [{ t: person }], n: 1 })).toEqual({ t: tokenOf(person), list: [{ t: tokenOf(person) }], n: 1 }) + }) +}) + +describe('the signer', () => { + it('an agent token names the agent in sub; a person or auth token in agent_id or agent', () => { + const agent = jwt('aa-agent+jwt', { iss: 'https://ap.example', sub: 'aauth:a@ap.example', jti: 'agt_1' }) + expect(signerOf(`sig=jwt; jwt="${agent}"`)).toEqual({ signed: { scheme: 'jwt', token: tokenOf(agent) }, from: 'aauth:a@ap.example', from_role: 'agent', agent: 'aauth:a@ap.example' }) + expect(signerOf(`sig=jwt; jwt="${person}"`).agent).toBe('aauth:a@ap.example') + const auth = jwt('aa-auth+jwt', { iss: 'https://access.example', agent: 'aauth:f@ap.example' }) + expect(signerOf(`sig=jwt; jwt="${auth}"`).from).toBe('aauth:f@ap.example') + const bare = jwt('aa-person+jwt', { iss: PS, sub: 'pw_1' }) + expect(signerOf(`sig=jwt; jwt="${bare}"`)).toEqual({ signed: { scheme: 'jwt', token: tokenOf(bare) }, from: undefined, from_role: undefined, agent: undefined }) + }) + + it('jwks_uri names the server and its role by dwk; hwk names nothing; junk is nothing', () => { + expect(signerOf('sig=jwks_uri; id="https://access.example"; dwk="aauth-access.json"; kid="k1"')).toEqual({ + signed: { scheme: 'jwks_uri', id: 'https://access.example', dwk: 'aauth-access.json', kid: 'k1' }, from: 'https://access.example', from_role: 'as', + }) + expect(signerOf('sig=hwk; jwk="{\\"kty\\":\\"OKP\\"}"')).toEqual({ signed: { scheme: 'hwk' } }) + expect(signerOf('not a dictionary =')).toEqual({}) + expect(signerOf(null)).toEqual({}) + }) +}) + +describe('params, error, level', () => { + it('parses the three AAuth headers and passes Location', () => { + const params = paramsOf(new Headers({ + 'aauth-requirement': 'requirement=interaction; url="https://ps.example/auth"; code="K7QX-M2"', + 'signature-error': 'error=revoked_jwt', + 'aauth-budget': 'cost=2; remaining=98; unit="credits"', + location: '/aauth/pending/pnd_1', + })) + expect(params).toEqual({ + 'AAuth-Requirement': { requirement: 'interaction', url: 'https://ps.example/auth', code: 'K7QX-M2' }, + 'Signature-Error': { error: 'revoked_jwt' }, + 'AAuth-Budget': { cost: '2', remaining: '98', unit: 'credits' }, + Location: '/aauth/pending/pnd_1', + }) + expect(paramsOf(new Headers({ 'content-type': 'application/json' }))).toBeUndefined() + expect(paramsOf({ 'aauth-requirement': 'requirement=person-token' })).toEqual({ 'AAuth-Requirement': { requirement: 'person-token' } }) + expect(errorOf(params, { error: 'other' })).toBe('revoked_jwt') + expect(errorOf(undefined, { error: 'invalid_request' })).toBe('invalid_request') + expect(errorOf(undefined, { error: { message: 'NO_SESSION' } })).toBe('NO_SESSION') + }) + + it('a challenge is 30, a refusal 40, our own 5xx 50, a peer failure 40', () => { + const chal = { params: { 'AAuth-Requirement': { requirement: 'person-token' } } } + expect(levelOf({ side: 'callee', status: 200 })).toBe(30) + expect(levelOf({ side: 'callee', status: 401, response: chal })).toBe(30) + expect(levelOf({ side: 'callee', status: 202, response: chal })).toBe(30) + expect(levelOf({ side: 'callee', status: 401 })).toBe(40) + expect(levelOf({ side: 'callee', status: 502 })).toBe(50) + expect(levelOf({ side: 'caller', status: 502 })).toBe(40) + expect(levelOf({ side: 'caller' })).toBe(40) + }) +}) + +describe('bodies', () => { + it('a JSON body becomes a value with its tokens as payloads; anything else its type and size', async () => { + const json = new Response(JSON.stringify({ person_token: person, n: 1 }), { headers: { 'content-type': 'application/json' } }) + expect(await partOf(json)).toEqual({ body: { person_token: tokenOf(person), n: 1 } }) + const sse = new Response('data: x\n\n', { headers: { 'content-type': 'text/event-stream', 'content-length': '9' } }) + expect(await partOf(sse)).toEqual({ content_type: 'text/event-stream', size: 9 }) + const bad = new Response('{not json', { headers: { 'content-type': 'application/json' } }) + expect(await partOf(bad)).toEqual({ content_type: 'application/json', size: 9 }) + expect(await partOf(null)).toBeUndefined() + expect(await partOf(null, { Location: '/x' })).toEqual({ params: { Location: '/x' } }) + expect(await partOf(new Response(null))).toBeUndefined() + }) + + it('the cap cuts the larger body to text and says so', () => { + const big = { entities: Array.from({ length: 2000 }, (_, i) => ({ id: i, name: `Robert Smith ${i}` })) } + const r = cap({ request: { body: { q: 'x' } }, response: { body: big } }) + expect(r.truncated).toBe(true) + expect(typeof r.response!.body).toBe('string') + expect(r.request!.body).toEqual({ q: 'x' }) + expect(Buffer.byteLength(JSON.stringify(r))).toBeLessThanOrEqual(MAX_RECORD_BYTES) + expect(cap({ request: { body: { q: 'x' } } }).truncated).toBeUndefined() + }) +}) + +describe('the record', () => { + it('has the level, a message, no undefined fields, and a target split from the url', () => { + const r = buildRecord({ side: 'callee', call_id: 'c', to: 'https://r.example', to_role: 'resource', method: 'POST', path: '/send', status: 401, started_at: 't', from: undefined, response: { params: { 'AAuth-Requirement': { requirement: 'person-token' } }, body: undefined } }) + expect(r).toEqual({ event: 'aauth.call', side: 'callee', call_id: 'c', to: 'https://r.example', to_role: 'resource', method: 'POST', path: '/send', status: 401, started_at: 't', response: { params: { 'AAuth-Requirement': { requirement: 'person-token' } } }, level: 30, msg: 'callee POST https://r.example/send → 401' }) + expect(buildRecord({ side: 'person', call_id: 'p', to: PS, action: 'approved', started_at: 't' }).msg).toBe('Person approved') + expect(targetOf('https://secret.agent.coop/public-key?from=a&to=b')).toEqual({ to: 'https://secret.agent.coop', path: '/public-key', query: 'from=a&to=b' }) + expect(targetOf('nonsense')).toEqual({ to: 'nonsense' }) + }) +}) diff --git a/call-log/src/record.ts b/call-log/src/record.ts new file mode 100644 index 0000000..6e2417b --- /dev/null +++ b/call-log/src/record.ts @@ -0,0 +1,346 @@ +// The aauth.call record, built from what one end of a call knows. Pure: +// no I/O, no clock beyond what the caller passes. The shape is +// aauth-dev/monitor plan/CALL_RECORD.md; the reference reader is the +// monitor's client/calls.js. +// +// Tokens are logged as { type, payload } — the `typ` header and the claims — +// wherever they appear: in `signed` and in place of the JWT string in a +// body, at any depth. Never the JWT, so no log holds a presentable token. + +export type Role = 'agent' | 'resource' | 'ps' | 'as' +export type Side = 'caller' | 'callee' | 'person' + +export interface Token { + type: string + payload: Record +} + +export type Signed = + | { scheme: 'jwt'; token?: Token } + | { scheme: 'jwks_uri'; id?: string; dwk?: string; kid?: string } + | { scheme: 'hwk'; jkt?: string } + +export interface Part { + body?: unknown + params?: Record + content_type?: string + size?: number +} + +export interface CallRecord { + event: 'aauth.call' + side: Side + call_id: string + parent?: string + from?: string + from_role?: Role + to: string + to_role?: Role + agent?: string + action?: string + method?: string + path?: string + query?: string + status?: number + started_at: string + duration_ms?: number + signed?: Signed + request?: Part + response?: Part + error?: string + truncated?: true + /** 30, 40 or 50 — see levelOf. */ + level: number + msg: string +} + +/** A wallet_events entry is capped at 32 KB (Wallet #4285); every party keeps to it. */ +export const MAX_RECORD_BYTES = 32 * 1024 - 2048 + +const ROLE_BY_DWK: Record = { + 'aauth-person.json': 'ps', + 'aauth-access.json': 'as', + 'aauth-resource.json': 'resource', + 'aauth-agent.json': 'agent', +} +const AGENT_TOKEN_TYPES = new Set(['aa-agent+jwt']) +const PARAM_HEADERS: Record = { + 'aauth-requirement': 'AAuth-Requirement', + 'signature-error': 'Signature-Error', + 'aauth-budget': 'AAuth-Budget', +} + +// ── base64url, SHA-256 ── + +const encoder = new TextEncoder() + +function b64url(bytes: Uint8Array): string { + let s = '' + for (const b of bytes) s += String.fromCharCode(b) + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function b64urlJson(part: string): Record | null { + try { + const padded = part + '='.repeat((4 - (part.length % 4)) % 4) + const bin = atob(padded.replace(/-/g, '+').replace(/_/g, '/')) + const bytes = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) + const json: unknown = JSON.parse(new TextDecoder().decode(bytes)) + return json && typeof json === 'object' && !Array.isArray(json) ? (json as Record) : null + } catch { + return null + } +} + +/** + * `call_id`: base64url SHA-256 of the Signature header. Both ends hold the + * header, so their records join with no new header on the wire. + */ +export async function callIdOf(signatureHeader: string | null | undefined): Promise { + if (!signatureHeader) return crypto.randomUUID() + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(signatureHeader)) + return b64url(new Uint8Array(digest)) +} + +// ── Tokens ── + +/** A compact JWS string as { type, payload }, or null. A JWE (five parts) stays as it is. */ +export function tokenOf(value: unknown): Token | null { + if (typeof value !== 'string' || value.length < 20) return null + const parts = value.split('.') + if (parts.length !== 3) return null + const header = b64urlJson(parts[0]) + if (!header || typeof header.alg !== 'string') return null + const payload = b64urlJson(parts[1]) + if (!payload) return null + return { type: typeof header.typ === 'string' ? header.typ : 'jwt', payload } +} + +/** A value with every token string, at any depth, replaced by { type, payload }. */ +export function tokenize(value: unknown, depth = 0): unknown { + if (depth > 12) return value + const token = tokenOf(value) + if (token) return token + if (Array.isArray(value)) return value.map((v) => tokenize(v, depth + 1)) + if (value && typeof value === 'object') { + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) out[k] = tokenize(v, depth + 1) + return out + } + return value +} + +// ── Signature-Key ── + +// An RFC 8941 dictionary with one member whose bare value is the scheme and +// whose parameters are strings or tokens — the only shape Signature-Key +// takes. Enough parser for that; anything else is null. +function parseSignatureKey(header: string): { scheme: string; params: Record } | null { + const m = /^\s*([A-Za-z*][\w.*-]*)\s*=\s*([A-Za-z*][\w:/.*-]*)\s*((?:;\s*[^;]*)*)$/.exec(header) + if (!m) return null + const params: Record = {} + for (const raw of m[3].split(';')) { + const p = raw.trim() + if (!p) continue + const eq = p.indexOf('=') + if (eq < 0) return null + const key = p.slice(0, eq).trim() + let value = p.slice(eq + 1).trim() + if (value.startsWith('"')) { + if (!value.endsWith('"')) return null + value = value.slice(1, -1).replace(/\\(["\\])/g, '$1') + } + params[key] = value + } + return { scheme: m[2], params } +} + +export interface Signer { + signed?: Signed + from?: string + from_role?: Role + agent?: string +} + +/** + * What signed a request, from its Signature-Key header, and who that makes + * the caller — as far as the header says, with no verification, so a + * refused call still names its caller. + */ +export function signerOf(signatureKeyHeader: string | null | undefined): Signer { + if (!signatureKeyHeader) return {} + const parsed = parseSignatureKey(signatureKeyHeader) + if (!parsed) return {} + const { scheme, params } = parsed + if (scheme === 'jwt') { + const token = tokenOf(params.jwt) + if (!token) return { signed: { scheme: 'jwt' } } + const { payload } = token + // An agent token names the agent in `sub`; a person or auth token in + // `agent_id` (the Hellō PS) or `agent` (the fleet's access server). + const named = [payload.agent_id, payload.agent].find((v): v is string => typeof v === 'string') + const agent = AGENT_TOKEN_TYPES.has(token.type) && typeof payload.sub === 'string' ? payload.sub : named + return { signed: { scheme: 'jwt', token }, from: agent, from_role: agent ? 'agent' : undefined, agent } + } + if (scheme === 'jwks_uri') { + const { id, dwk, kid } = params + return { signed: { scheme: 'jwks_uri', id, dwk, kid }, from: id, from_role: dwk ? ROLE_BY_DWK[dwk] : undefined } + } + if (scheme === 'hwk') return { signed: { scheme: 'hwk' } } + return {} +} + +// ── Response headers with protocol meaning ── + +// `requirement=interaction; url="…"; code="…"`, `error=revoked_jwt`, +// `cost=2; remaining=98; unit="credits"`: key=value pairs, quotes dropped. +function parseParamHeader(value: string): Record | string { + const out: Record = {} + for (const pair of value.split(';')) { + const m = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*?)\s*$/.exec(pair) + if (!m) return value + const raw = m[2] + out[m[1]] = raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1).replace(/\\(["\\])/g, '$1') : raw + } + return Object.keys(out).length ? out : value +} + +type HeadersLike = Headers | Record + +function header(headers: HeadersLike, name: string): string | undefined { + const v = typeof (headers as Headers).get === 'function' ? (headers as Headers).get(name) : (headers as Record)[name] + const one = Array.isArray(v) ? v[0] : v + return typeof one === 'string' && one ? one : undefined +} + +/** AAuth-Requirement, Signature-Error and AAuth-Budget parsed; Location as is. */ +export function paramsOf(headers: HeadersLike | null | undefined): Record | undefined { + if (!headers) return undefined + const out: Record = {} + for (const [name, shown] of Object.entries(PARAM_HEADERS)) { + const v = header(headers, name) + if (v) out[shown] = parseParamHeader(v) + } + const location = header(headers, 'location') + if (location) out.Location = location + return Object.keys(out).length ? out : undefined +} + +/** The error code of a refusal: the Signature-Error `error`, else the body's. */ +export function errorOf(params: Record | undefined, body: unknown): string | undefined { + const sigErr = (params?.['Signature-Error'] as { error?: unknown } | undefined)?.error + if (typeof sigErr === 'string') return sigErr + if (body && typeof body === 'object') { + const err = (body as { error?: unknown }).error + if (typeof err === 'string') return err.slice(0, 64) + const message = (err as { message?: unknown } | undefined)?.message + if (typeof message === 'string') return message.slice(0, 64) + } + return undefined +} + +const isChallenge = (r: { status?: number; response?: Part }) => + (r.status === 401 || r.status === 202) && !!r.response?.params?.['AAuth-Requirement'] + +/** + * 30; 40 for a 4xx that is not a challenge, a peer's 5xx, or a fetch that + * threw; 50 only for a 5xx the logging party answered itself. + */ +export function levelOf(r: { side: Side; status?: number; response?: Part }): number { + if (r.side === 'callee' && r.status !== undefined && r.status >= 500) return 50 + if (r.status === undefined || (r.status >= 400 && !isChallenge(r))) return 40 + return 30 +} + +// ── Bodies and the cap ── + +const JSON_TYPES = /json/i + +/** What a body becomes in the record: JSON under the cap as a value, anything else as its type and size. */ +export async function partOf( + body: { text: () => Promise; headers: HeadersLike } | null | undefined, + params?: Record, +): Promise { + const out: Part = {} + if (params) out.params = params + if (body) { + const content_type = header(body.headers, 'content-type') + const length = Number(header(body.headers, 'content-length')) + const size = Number.isFinite(length) && length > 0 ? length : undefined + if (content_type && JSON_TYPES.test(content_type) && (size === undefined || size <= MAX_RECORD_BYTES * 4)) { + try { + const text = await body.text() + if (text) { + try { + out.body = tokenize(JSON.parse(text)) + } catch { + out.content_type = content_type + out.size = encoder.encode(text).length + } + } + } catch { + if (content_type) out.content_type = content_type + } + } else if (content_type) { + out.content_type = content_type + if (size !== undefined) out.size = size + } + } + return Object.keys(out).length ? out : undefined +} + +/** Cut the larger body to text until the record fits the cap; say so. */ +export function cap(input: T): T & { truncated?: true } { + const record = input as T & { truncated?: true } + const size = () => encoder.encode(JSON.stringify(record)).length + let bytes = size() + if (bytes <= MAX_RECORD_BYTES) return record + const bodies = (['request', 'response'] as const) + .filter((k) => record[k]?.body !== undefined) + .map((k) => ({ k, text: JSON.stringify(record[k]!.body) })) + .sort((a, b) => b.text.length - a.text.length) + for (const { k, text } of bodies) { + let room = Math.max(256, text.length - (bytes - MAX_RECORD_BYTES) - 64) + for (;;) { + record[k] = { ...record[k], body: text.slice(0, room) } + record.truncated = true + bytes = size() + if (bytes <= MAX_RECORD_BYTES || room <= 256) break + room = Math.max(256, room - (bytes - MAX_RECORD_BYTES) - 64) + } + if (bytes <= MAX_RECORD_BYTES) break + } + return record +} + +// ── The record ── + +export type RecordFields = Omit + +/** Fields in, a finished record out: level, message, nothing undefined, under the cap. */ +export function buildRecord(fields: RecordFields): CallRecord { + const clean = (obj: Record) => { + for (const k of Object.keys(obj)) if (obj[k] === undefined || obj[k] === null) delete obj[k] + return obj + } + const record = clean({ event: 'aauth.call', ...fields }) as unknown as CallRecord + if (record.request) record.request = clean({ ...record.request }) as Part + if (record.response) record.response = clean({ ...record.response }) as Part + record.level = levelOf(record) + record.msg = + record.side === 'person' + ? `Person ${record.action ?? 'acted'}` + : `${record.side} ${record.method ?? ''} ${record.to}${record.path ?? ''} → ${record.status ?? 'failed'}` + return cap(record) +} + +/** `https://host/path?q` → { to, path, query }. */ +export function targetOf(url: string): { to: string; path?: string; query?: string } { + try { + const u = new URL(url) + return { to: u.origin, path: u.pathname, query: u.search.slice(1) || undefined } + } catch { + return { to: url } + } +} diff --git a/call-log/src/sides.test.ts b/call-log/src/sides.test.ts new file mode 100644 index 0000000..780a410 --- /dev/null +++ b/call-log/src/sides.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from 'vitest' +import { createHash } from 'node:crypto' +import { callLogMiddleware, loggedFetch, loggedHttpsigFetch, failedFetch, nameAgent, parentFromContext, runInCall, type CallRecord, type CallLogHost } from './index.js' + +const sha = (s: string) => createHash('sha256').update(s).digest('base64url') +const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url') +const agentJwt = `${b64({ alg: 'Ed25519', typ: 'aa-agent+jwt' })}.${b64({ iss: 'https://ap.example', sub: 'aauth:owl@ap.example', jti: 'agt_1' })}.c2ln` + +/** A host that keeps its records and runs deferred work so a test can await it. */ +function testHost(role: CallLogHost['role'] = 'resource') { + const records: CallRecord[] = [] + const pending: Promise[] = [] + const host: CallLogHost = { origin: 'https://encrypt.aauth.dev', role, log: (r) => { records.push(r) }, defer: (p) => { pending.push(p) } } + const settled = async () => { await Promise.all(pending) } + return { host, records, settled } +} + +/** The little of Hono the middleware touches, driven by hand. */ +function contextFor(request: Request, handler: () => Promise) { + const c = { req: { raw: request }, res: new Response(null, { status: 404 }) } + const next = async () => { c.res = await handler() } + return { c, next } +} + +describe('the callee side', () => { + it('names the call from the Signature header, reads both bodies, and logs one record after the response', async () => { + const { host, records, settled } = testHost() + const mw = callLogMiddleware(host) + const request = new Request('https://encrypt.aauth.dev/send?dry=1', { + method: 'POST', + headers: { 'content-type': 'application/json', signature: 'sig=:AAAA:', 'signature-key': `sig=jwt; jwt="${agentJwt}"` }, + body: JSON.stringify({ to: 'mailto:bob@example.com' }), + }) + let parentSeen: string | undefined + let bodySeenByHandler: unknown + const { c, next } = contextFor(request, async () => { + parentSeen = parentFromContext() + bodySeenByHandler = await request.json() // the handler still reads the body + return Response.json({ error: 'person_token_required' }, { status: 401, headers: { 'AAuth-Requirement': 'requirement=person-token' } }) + }) + await mw(c, next) + expect(records).toEqual([]) // nothing on the request path + await settled() + expect(bodySeenByHandler).toEqual({ to: 'mailto:bob@example.com' }) + expect(parentSeen).toBe(sha('sig=:AAAA:')) + expect(records).toHaveLength(1) + const [r] = records + expect(r).toMatchObject({ + side: 'callee', call_id: sha('sig=:AAAA:'), from: 'aauth:owl@ap.example', from_role: 'agent', agent: 'aauth:owl@ap.example', + to: 'https://encrypt.aauth.dev', to_role: 'resource', method: 'POST', path: '/send', query: 'dry=1', status: 401, level: 30, + signed: { scheme: 'jwt', token: { type: 'aa-agent+jwt' } }, + request: { body: { to: 'mailto:bob@example.com' } }, + response: { params: { 'AAuth-Requirement': { requirement: 'person-token' } }, body: { error: 'person_token_required' } }, + error: 'person_token_required', + }) + expect(r.duration_ms).toBeTypeOf('number') + expect(r.parent).toBeUndefined() + }) + + it('takes the agent from the verifier when the token names none, and skips what is not a call', async () => { + const { host, records, settled } = testHost('ps') + const mw = callLogMiddleware(host) + const personJwt = `${b64({ alg: 'Ed25519', typ: 'aa-person+jwt' })}.${b64({ iss: 'https://ps.example', sub: 'pw_1', jti: 'ptk_1' })}.c2ln` + const request = new Request('https://encrypt.aauth.dev/aauth/person', { headers: { signature: 'sig=:BBBB:', 'signature-key': `sig=jwt; jwt="${personJwt}"` } }) + const { c, next } = contextFor(request, async () => { nameAgent('aauth:fox@ap.example'); return Response.json({ sub: 'pw_1' }) }) + await mw(c, next) + await settled() + expect(records[0]).toMatchObject({ from: 'aauth:fox@ap.example', from_role: 'agent', agent: 'aauth:fox@ap.example', status: 200 }) + for (const url of ['https://encrypt.aauth.dev/.well-known/aauth-resource.json', 'https://encrypt.aauth.dev/health']) { + const skipped = contextFor(new Request(url), async () => Response.json({})) + await mw(skipped.c, skipped.next) + } + const options = contextFor(new Request('https://encrypt.aauth.dev/send', { method: 'OPTIONS' }), async () => new Response(null, { status: 204 })) + await mw(options.c, options.next) + await settled() + expect(records).toHaveLength(1) + }) + + it('an unsigned call has a random id and no caller; its own 5xx is error level', async () => { + const { host, records, settled } = testHost() + const { c, next } = contextFor(new Request('https://encrypt.aauth.dev/send', { method: 'POST' }), async () => new Response('boom', { status: 500 })) + await callLogMiddleware(host)(c, next) + await settled() + expect(records[0]).toMatchObject({ status: 500, level: 50 }) + expect(records[0].call_id).toMatch(/^[0-9a-f-]{36}$/) + expect(records[0].from).toBeUndefined() + expect(records[0].signed).toBeUndefined() + }) +}) + +describe('the caller side', () => { + const sent = (signature: string) => ({ headers: new Headers({ signature, 'signature-key': 'sig=jwks_uri; id="https://encrypt.aauth.dev"; dwk="aauth-agent.json"; kid="k"' }) }) + + it('loggedFetch: one signed fetch per call, call_id from what it sent, the parent from the context, the reply read from a clone', async () => { + const { host, records, settled } = testHost() + const makeFetch = (onSigned: (s: { headers: Headers }) => void) => async (url: string) => { + onSigned(sent(`sig=:${url.endsWith('/a') ? 'AAAA' : 'BBBB'}:`)) + return Response.json({ person_token: agentJwt }, { headers: { 'content-type': 'application/json' } }) + } + const fetchA = loggedFetch(makeFetch, host, { to_role: 'ps', agent: 'aauth:owl@ap.example' }) + const [ra, rb] = await runInCall({ callId: 'parent-1' }, () => Promise.all([ + fetchA('https://ps.example/aauth/token/person?x=1', { method: 'POST', body: JSON.stringify({ resource: 'https://r.example', upstream_token: agentJwt }) }), + fetchA('https://ps.example/b'), + ])) + expect(await ra.json()).toMatchObject({ person_token: agentJwt }) // the caller's own read is untouched + expect(rb.status).toBe(200) + await settled() + expect(records).toHaveLength(2) + const a = records.find((r) => r.path === '/aauth/token/person')! + expect(a).toMatchObject({ + side: 'caller', call_id: sha('sig=:BBBB:'), parent: 'parent-1', from: 'https://encrypt.aauth.dev', from_role: 'resource', + to: 'https://ps.example', to_role: 'ps', agent: 'aauth:owl@ap.example', method: 'POST', query: 'x=1', status: 200, level: 30, + signed: { scheme: 'jwks_uri', id: 'https://encrypt.aauth.dev' }, + request: { body: { resource: 'https://r.example', upstream_token: { type: 'aa-agent+jwt' } } }, + response: { body: { person_token: { type: 'aa-agent+jwt' } } }, + }) + expect(records.find((r) => r.path === '/b')!.call_id).toBe(sha('sig=:BBBB:')) + expect(records.find((r) => r.path === '/b')!.request).toBeUndefined() + }) + + it('an explicit parent wins over the context; outside any call there is none', async () => { + const { host, records, settled } = testHost() + const makeFetch = (onSigned: (s: { headers: Headers }) => void) => async () => { onSigned(sent('sig=:C:')); return new Response(null, { status: 204 }) } + await runInCall({ callId: 'ctx' }, () => loggedFetch(makeFetch, host, { parent: 'given' })('https://r.example/x')) + await loggedFetch(makeFetch, host)('https://r.example/y') + await settled() + expect(records.map((r) => r.parent)).toEqual(['given', undefined]) + }) + + it('a refusal carries the error code; a throw is a record with no status, at warn', async () => { + const { host, records, settled } = testHost() + const refuse = (onSigned: (s: { headers: Headers }) => void) => async () => { onSigned(sent('sig=:D:')); return Response.json({ error: 'unsupported_iss' }, { status: 403 }) } + await loggedFetch(refuse, host)('https://as.example/revoke', { method: 'POST', body: '{"jti":"x"}' }) + const boom = () => async () => { throw Object.assign(new Error('aborted'), { name: 'TimeoutError' }) } + await expect(loggedFetch(boom, host)('https://as.example/revoke')).rejects.toThrow('aborted') + await settled() + const outcomes = records.map((r) => [r.status, r.error, r.level]).sort((a, b) => String(a[1]).localeCompare(String(b[1]))) + expect(outcomes).toEqual([[undefined, 'timeout', 40], [403, 'unsupported_iss', 40]]) + }) + + it('loggedHttpsigFetch asks for the sent request and unwraps it', async () => { + const { host, records, settled } = testHost('ps') + const seen: Record[] = [] + const httpsig = async (_url: string, options: Record) => { + seen.push(options) + return { response: Response.json({ auth_token: agentJwt }, { headers: { 'AAuth-Budget': 'cost=1; remaining=9; unit="credits"' } }), sent: sent('sig=:E:') } + } + const res = await loggedHttpsigFetch(httpsig, host, { to_role: 'as' })('https://as.example/token', { method: 'POST', body: '{}', signingKey: {} }) + expect(res.status).toBe(200) + expect(seen[0].returnSent).toBe(true) + await settled() + expect(records[0]).toMatchObject({ call_id: sha('sig=:E:'), to_role: 'as', response: { params: { 'AAuth-Budget': { cost: '1', remaining: '9', unit: 'credits' } }, body: { auth_token: { type: 'aa-agent+jwt' } } } }) + }) + + it('failedFetch records a fetch the caller could not wrap', () => { + const { host, records } = testHost('ps') + runInCall({ callId: 'p' }, () => failedFetch(host, { url: 'https://r.example/.well-known/aauth-resource.json', to_role: 'resource', status: 404 })) + expect(records[0]).toMatchObject({ side: 'caller', parent: 'p', method: 'GET', path: '/.well-known/aauth-resource.json', status: 404, level: 40 }) + }) +}) diff --git a/call-log/tsconfig.json b/call-log/tsconfig.json new file mode 100644 index 0000000..374c623 --- /dev/null +++ b/call-log/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/package-lock.json b/package-lock.json index a768797..e0c9023 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "resource", "mcp-stdio", "mcp-openclaw", - "fetch" + "fetch", + "call-log" ], "devDependencies": { "@hellocoop/mockin": "^3.0.0", @@ -49,6 +50,15 @@ "aauth-bootstrap": "dist/cli.js" } }, + "call-log": { + "name": "@aauth/call-log", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } + }, "fetch": { "name": "@aauth/fetch", "version": "4.0.0", @@ -148,6 +158,10 @@ "resolved": "bootstrap", "link": true }, + "node_modules/@aauth/call-log": { + "resolved": "call-log", + "link": true + }, "node_modules/@aauth/fetch": { "resolved": "fetch", "link": true diff --git a/package.json b/package.json index c9d959e..5b788af 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "resource", "mcp-stdio", "mcp-openclaw", - "fetch" + "fetch", + "call-log" ] } diff --git a/vitest.config.ts b/vitest.config.ts index f8f2221..9b2b6f0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ // unbuilt `dist/`. Resolve it from source like every other workspace. '@aauth/interaction-code': path.resolve(__dirname, 'interaction-code/src/index.ts'), '@aauth/hardware-keys': path.resolve(__dirname, 'hardware-keys/index.js'), + '@aauth/call-log': path.resolve(__dirname, 'call-log/src/index.ts'), }, }, test: { From ad49ee0f4546de4dd2e94f2f6adf670a2bd04a1a Mon Sep 17 00:00:00 2001 From: dickhardt Date: Fri, 25 Sep 2026 22:50:14 +0100 Subject: [PATCH 2/3] call-log: FetchLike takes string | URL, as @aauth/agent's does Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SdTYzN8NvzehCVCSqhadH5 --- call-log/src/caller.ts | 6 +++--- call-log/src/sides.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/call-log/src/caller.ts b/call-log/src/caller.ts index b6a022d..8c41c72 100644 --- a/call-log/src/caller.ts +++ b/call-log/src/caller.ts @@ -42,7 +42,7 @@ const headerOf = (h: SentLike['headers'] | undefined, name: string): string | un async function record( host: CallLogHost, call: CallOptions, - url: string, + url: string | URL, init: Init | undefined, started: Date, sent: SentLike | undefined, @@ -65,7 +65,7 @@ async function record( started_at: started.toISOString(), signed: signer.signed, request: requestBody === undefined ? undefined : { body: tokenize(requestBody) }, - ...targetOf(url), + ...targetOf(String(url)), } if ('error' in outcome) { const err = outcome.error as { name?: string; message?: string } | undefined @@ -87,7 +87,7 @@ function safeJson(text: string): unknown { } } -export type FetchLike = (url: string, init?: Init) => Promise +export type FetchLike = (url: string | URL, init?: Init) => Promise /** * For @aauth/agent's createSignedFetch. `makeFetch` is called once per call diff --git a/call-log/src/sides.test.ts b/call-log/src/sides.test.ts index 780a410..68b75b6 100644 --- a/call-log/src/sides.test.ts +++ b/call-log/src/sides.test.ts @@ -93,8 +93,8 @@ describe('the caller side', () => { it('loggedFetch: one signed fetch per call, call_id from what it sent, the parent from the context, the reply read from a clone', async () => { const { host, records, settled } = testHost() - const makeFetch = (onSigned: (s: { headers: Headers }) => void) => async (url: string) => { - onSigned(sent(`sig=:${url.endsWith('/a') ? 'AAAA' : 'BBBB'}:`)) + const makeFetch = (onSigned: (s: { headers: Headers }) => void) => async (url: string | URL) => { + onSigned(sent(`sig=:${String(url).endsWith('/a') ? 'AAAA' : 'BBBB'}:`)) return Response.json({ person_token: agentJwt }, { headers: { 'content-type': 'application/json' } }) } const fetchA = loggedFetch(makeFetch, host, { to_role: 'ps', agent: 'aauth:owl@ap.example' }) From 6b287cc339369ff6c09b431dba2c335b10d78ee1 Mon Sep 17 00:00:00 2001 From: dickhardt Date: Fri, 25 Sep 2026 22:55:38 +0100 Subject: [PATCH 3/3] call-log: signed.jkt, the RFC 7638 thumbprint of the signer's cnf.jwk The monitor learns which agent holds a key from any record that names both, and fills in the rows where only the key is known. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SdTYzN8NvzehCVCSqhadH5 --- call-log/src/callee.ts | 4 ++-- call-log/src/caller.ts | 4 ++-- call-log/src/index.ts | 2 +- call-log/src/record.test.ts | 25 ++++++++++++++++++++++++- call-log/src/record.ts | 25 ++++++++++++++++++++++++- 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/call-log/src/callee.ts b/call-log/src/callee.ts index 8f588b0..4dbd76f 100644 --- a/call-log/src/callee.ts +++ b/call-log/src/callee.ts @@ -7,7 +7,7 @@ // nothing: `c.req.raw` (a Request), `c.res` (a Response after `next()`), // `c.executionCtx.waitUntil` when there is one. -import { callIdOf, signerOf, paramsOf, errorOf, partOf, buildRecord } from './record.js' +import { callIdOf, signerOf, withThumbprint, paramsOf, errorOf, partOf, buildRecord } from './record.js' import { runInCall, currentCall, type CallContext } from './context.js' import { emit, defer, type CallLogHost } from './host.js' @@ -63,7 +63,7 @@ export function callLogMiddleware(host: CallLogHost, options: CalleeOptions = {} defer( hostWithCtx, (async () => { - const signer = signerOf(request.headers.get('signature-key')) + const signer = await withThumbprint(signerOf(request.headers.get('signature-key'))) const agent = context.agent ?? signer.agent const params = paramsOf(response.headers) const url = new URL(request.url) diff --git a/call-log/src/caller.ts b/call-log/src/caller.ts index 8c41c72..d8f2df3 100644 --- a/call-log/src/caller.ts +++ b/call-log/src/caller.ts @@ -13,7 +13,7 @@ // off the caller's path, through host.defer. `parent` is the call being // handled (AsyncLocalStorage) unless the caller passes one. -import { callIdOf, signerOf, paramsOf, errorOf, partOf, buildRecord, tokenize, targetOf, type Role, type Signed } from './record.js' +import { callIdOf, signerOf, withThumbprint, paramsOf, errorOf, partOf, buildRecord, tokenize, targetOf, type Role, type Signed } from './record.js' import { parentFromContext, currentCall } from './context.js' import { emit, defer, type CallLogHost } from './host.js' @@ -50,7 +50,7 @@ async function record( ): Promise { const parent = call.parent ?? parentFromContext() const agent = call.agent ?? currentCall()?.agent - const signer: { signed?: Signed } = sent ? signerOf(headerOf(sent.headers, 'signature-key')) : {} + const signer: { signed?: Signed } = sent ? await withThumbprint(signerOf(headerOf(sent.headers, 'signature-key'))) : {} const call_id = await callIdOf(sent ? headerOf(sent.headers, 'signature') : undefined) const requestBody = typeof init?.body === 'string' ? safeJson(init.body) : undefined const base = { diff --git a/call-log/src/index.ts b/call-log/src/index.ts index 270bf65..02445ec 100644 --- a/call-log/src/index.ts +++ b/call-log/src/index.ts @@ -8,7 +8,7 @@ // which agent a verified call is for. export type { CallRecord, RecordFields, Role, Side, Token, Signed, Part, Signer } from './record.js' -export { callIdOf, tokenOf, tokenize, signerOf, paramsOf, errorOf, levelOf, partOf, cap, buildRecord, targetOf, MAX_RECORD_BYTES } from './record.js' +export { callIdOf, tokenOf, tokenize, signerOf, thumbprintOf, withThumbprint, paramsOf, errorOf, levelOf, partOf, cap, buildRecord, targetOf, MAX_RECORD_BYTES } from './record.js' export type { CallLogHost } from './host.js' export { emit } from './host.js' export type { CallContext } from './context.js' diff --git a/call-log/src/record.test.ts b/call-log/src/record.test.ts index 2bb36d2..9f2af71 100644 --- a/call-log/src/record.test.ts +++ b/call-log/src/record.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { createHash } from 'node:crypto' -import { callIdOf, tokenOf, tokenize, signerOf, paramsOf, errorOf, levelOf, partOf, cap, buildRecord, targetOf, MAX_RECORD_BYTES } from './index.js' +import { callIdOf, tokenOf, tokenize, signerOf, thumbprintOf, withThumbprint, paramsOf, errorOf, levelOf, partOf, cap, buildRecord, targetOf, MAX_RECORD_BYTES } from './index.js' const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url') const jwt = (typ: string, payload: Record) => `${b64({ alg: 'Ed25519', typ })}.${b64(payload)}.c2ln` @@ -44,6 +44,29 @@ describe('the signer', () => { }) }) +describe('the key thumbprint', () => { + // RFC 7638 §3.1's example: this is the thumbprint its text gives. + const rsa = { kty: 'RSA', n: '0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw', e: 'AQAB' } + const rsaJkt = 'NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs' + + it('is RFC 7638, and undefined for a key it cannot name', async () => { + expect(await thumbprintOf(rsa)).toBe(rsaJkt) + expect(await thumbprintOf({ ...rsa, alg: 'RS256', kid: 'x', use: 'sig' })).toBe(rsaJkt) // extra members are not in it + expect(await thumbprintOf({ kty: 'OKP', crv: 'Ed25519', x: 'abc' })).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(await thumbprintOf({ kty: 'EC', crv: 'P-256', x: 'a' })).toBeUndefined() // no y + expect(await thumbprintOf(null)).toBeUndefined() + expect(await thumbprintOf('not a key')).toBeUndefined() + }) + + it('a jwt signer gains its key thumbprint from cnf.jwk; others are untouched', async () => { + const bound = jwt('aa-person+jwt', { iss: PS, sub: 'pw_1', cnf: { jwk: rsa } }) + const signer = await withThumbprint(signerOf(`sig=jwt; jwt="${bound}"`)) + expect(signer.signed).toMatchObject({ scheme: 'jwt', jkt: rsaJkt }) + expect((await withThumbprint(signerOf(`sig=jwt; jwt="${person}"`))).signed).not.toHaveProperty('jkt') + expect((await withThumbprint(signerOf('sig=jwks_uri; id="https://a.example"; dwk="aauth-access.json"; kid="k"'))).signed).toEqual({ scheme: 'jwks_uri', id: 'https://a.example', dwk: 'aauth-access.json', kid: 'k' }) + }) +}) + describe('params, error, level', () => { it('parses the three AAuth headers and passes Location', () => { const params = paramsOf(new Headers({ diff --git a/call-log/src/record.ts b/call-log/src/record.ts index 6e2417b..1db699d 100644 --- a/call-log/src/record.ts +++ b/call-log/src/record.ts @@ -16,7 +16,8 @@ export interface Token { } export type Signed = - | { scheme: 'jwt'; token?: Token } + /** `jkt`: RFC 7638 thumbprint of the token's `cnf.jwk` — the key that signed. The monitor learns which agent holds a key from any record naming both. */ + | { scheme: 'jwt'; token?: Token; jkt?: string } | { scheme: 'jwks_uri'; id?: string; dwk?: string; kid?: string } | { scheme: 'hwk'; jkt?: string } @@ -103,6 +104,28 @@ export async function callIdOf(signatureHeader: string | null | undefined): Prom return b64url(new Uint8Array(digest)) } +/** RFC 7638 thumbprint of a public JWK, base64url; undefined for a key it cannot name. */ +export async function thumbprintOf(jwk: unknown): Promise { + const k = jwk as Record | null + if (!k || typeof k !== 'object' || typeof k.kty !== 'string') return undefined + const members: Record = { EC: ['crv', 'kty', 'x', 'y'], OKP: ['crv', 'kty', 'x'], RSA: ['e', 'kty', 'n'], oct: ['k', 'kty'] } + const names = members[k.kty] + if (!names || names.some((n) => typeof k[n] !== 'string')) return undefined + const canonical = JSON.stringify(Object.fromEntries(names.map((n) => [n, k[n]]))) + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(canonical)) + return b64url(new Uint8Array(digest)) +} + +/** The signer with its key's thumbprint filled in, for a jwt scheme whose token carries cnf.jwk. */ +export async function withThumbprint(signer: T): Promise { + const signed = signer.signed + if (signed?.scheme === 'jwt' && signed.token) { + const jkt = await thumbprintOf((signed.token.payload.cnf as { jwk?: unknown } | undefined)?.jwk) + if (jkt) signed.jkt = jkt + } + return signer +} + // ── Tokens ── /** A compact JWS string as { type, payload }, or null. A JWE (five parts) stays as it is. */