Something went wrong. Try again.
A local-first event pipeline for independent agents, built on Jazz.
Something went wrong. Try again.
4.6 kB · 133 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
const DEFAULT_MAX_SKEW_MS = 30_000;const DEFAULT_MAX_NONCES = 1_024;
export interface BodyBoundRequestSignatureInput { method: string; path: string; body: Buffer; timestamp?: number | undefined; nonce?: string | undefined;}
export interface BodyBoundRequestSignature { timestamp: string; nonce: string; signature: string;}
export function signBodyBoundRequest( key: Buffer, input: BodyBoundRequestSignatureInput, label: string,): BodyBoundRequestSignature { assertCapabilityKey(key, label); const timestamp = String(input.timestamp ?? Date.now()); const nonce = input.nonce ?? randomBytes(24).toString("base64url"); if (!/^\d{13}$/.test(timestamp) || !/^[A-Za-z0-9_-]{32}$/.test(nonce)) { throw new Error(`${label} request signature inputs are invalid`); } return { timestamp, nonce, signature: signatureFor(key, input.method, input.path, input.body, timestamp, nonce), };}
export class BodyBoundCapabilityVerifier { private readonly seen = new Map<string, number>();
constructor( private readonly key: Buffer, private readonly label: string, private readonly options: { now?: (() => number) | undefined; maxSkewMs?: number | undefined; maxNonces?: number | undefined; } = {}, ) { assertCapabilityKey(key, label); const skew = options.maxSkewMs ?? DEFAULT_MAX_SKEW_MS; const maxNonces = options.maxNonces ?? DEFAULT_MAX_NONCES; if (!Number.isSafeInteger(skew) || skew < 1_000 || skew > 5 * 60_000) { throw new Error(`${label} capability skew bound is invalid`); } if (!Number.isSafeInteger(maxNonces) || maxNonces < 16 || maxNonces > 100_000) { throw new Error(`${label} capability nonce bound is invalid`); } }
verify( headers: Record<string, string | string[] | undefined>, names: { timestamp: string; nonce: string; signature: string }, method: string, path: string, body: Buffer, ): boolean { const timestamp = oneHeader(headers[names.timestamp]); const nonce = oneHeader(headers[names.nonce]); const signature = oneHeader(headers[names.signature]); if (!timestamp || !nonce || !signature) return false; if (!/^\d{13}$/.test(timestamp) || !/^[A-Za-z0-9_-]{32}$/.test(nonce) || !/^[A-Za-z0-9_-]{43}$/.test(signature)) { return false; } const now = (this.options.now ?? Date.now)(); const at = Number(timestamp); const maxSkewMs = this.options.maxSkewMs ?? DEFAULT_MAX_SKEW_MS; this.prune(now, maxSkewMs); if (!Number.isSafeInteger(at) || Math.abs(now - at) > maxSkewMs || this.seen.has(nonce)) return false; const expected = signatureFor(this.key, method, path, body, timestamp, nonce); if (!safeStringEqual(signature, expected)) return false; const maxNonces = this.options.maxNonces ?? DEFAULT_MAX_NONCES; if (this.seen.size >= maxNonces) return false; this.seen.set(nonce, at); return true; }
private prune(now: number, maxSkewMs: number): void { for (const [nonce, at] of this.seen) { if (now - at > maxSkewMs) this.seen.delete(nonce); } }}
export function decodeBodyBoundCapability( value: string | undefined, label: string,): Buffer | undefined { if (!value?.trim()) return undefined; const normalized = value.replaceAll(/\s+/g, ""); const key = Buffer.from(normalized, "base64"); if (key.toString("base64") !== normalized) throw new Error(`${label} capability must be canonical base64`); assertCapabilityKey(key, label); return key;}
function assertCapabilityKey(key: Buffer, label: string): void { if (key.length < 32 || key.length > 128) throw new Error(`${label} capability must contain 32 to 128 bytes`);}
function signatureFor( key: Buffer, method: string, path: string, body: Buffer, timestamp: string, nonce: string,): string { const bodyDigest = createHash("sha256").update(body).digest("hex"); const canonical = `${timestamp}\n${nonce}\n${method.toUpperCase()}\n${path}\n${bodyDigest}`; return createHmac("sha256", key).update(canonical, "utf8").digest("base64url");}
function safeStringEqual(left: string, right: string): boolean { const leftDigest = Buffer.from(left, "utf8"); const rightDigest = Buffer.from(right, "utf8"); return leftDigest.length === rightDigest.length && timingSafeEqual(leftDigest, rightDigest);}
function oneHeader(value: string | string[] | undefined): string | undefined { if (Array.isArray(value)) return value.length === 1 ? value[0] : undefined; return value;}