/** * @file webkeys: URL-borne bearer permissions, per Tyler Close's * ["web-key: Mashing with permission"](https://waterken.sourceforge.net/web-key/) * (IEEE W2SP 2008); the design abstract lives in * [docs/webkeys.md](../../docs/webkeys.md), with the Waterken precursor * code in `kpreid/e-on-java` as provenance (YURL32 = `org.waterken.uri. * Base32` / `BigIntegerSugar.toYURL32`; bearer invariant = `EARL.java`; * identity-without-authority = `SwissTable`'s swissHash). * * A webkey separates secret from address: * - the **secret bits**: supplied directly by the caller as a Worker * unique ID (e.g. `DurableObjectId` from `ns.newUniqueId()`), or raw * bytes (`Uint8Array`). Per [OCap discipline] * (.opencode/skills/ocap-discipline/SKILL.md), functions here are pure: * no ambient RNG calls, no callback indirection. The caller holds the * minting authority at the application boundary and supplies the result. * - the **encoding**: YURL32 — lowercase `a–z` + `2–7`, 5 bits/char, * URL-safe by construction (no `-`, no `_`, no case to misread). * * Cloudflare Worker `DurableObjectId`s are 256 bits (32 bytes), rendered * as 64 hex characters by `id.toString()`. {@link toWebkey} encodes that * ID into 52 YURL32 glyphs; {@link idFromWebkey} decodes it back to the * 64-hex string expected by `ns.idFromString()`. * * {@link shashKey} gives the identity-without-authority twin (the * swissHash discipline) for log-correlation: one-way, on SHA-256. */ /** the YURL32 alphabet: `a–z` then `2–7` (32 glyphs, 5 bits each) */ const YURL32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567" as const; /** encode bytes as YURL32 (`a–z2–7`, 5 bits/char; no padding, the tail * byte's leftover bits shift-left, as `org.waterken.uri.Base32.encode` * does) */ export function yurl32(bytes: Uint8Array): string { let out = ""; let buffer = 0; let bits = 0; for (const byte of bytes) { buffer = (buffer << 8) | byte; bits += 8; while (bits >= 5) { out += YURL32_ALPHABET[(buffer >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) { out += YURL32_ALPHABET[(buffer << (5 - bits)) & 31]; } return out; } /** decode YURL32 back to bytes (inverse of {@link yurl32}); throws on * any character outside the alphabet — a webkey is either a webkey or * it is not */ export function unyurl32(text: string): Uint8Array { let buffer = 0; let bits = 0; const out: number[] = []; for (const ch of text) { const v = YURL32_ALPHABET.indexOf(ch); if (v < 0) throw new Error(`not a webkey: ${JSON.stringify(ch)} is not YURL32`); buffer = (buffer << 5) | v; bits += 5; if (bits >= 8) { out.push((buffer >>> (bits - 8)) & 0xff); bits -= 8; } } // leftover bits (<8) are the encoder's zero-padding: validate, don't // carry them if (bits > 0 && (buffer & ((1 << bits) - 1)) !== 0) { throw new Error("not a webkey: non-zero tail bits"); } return new Uint8Array(out); } /** parse a hexadecimal string to raw bytes */ export function hexToBytes(hex: string): Uint8Array { const trimmed = hex.trim(); if (trimmed.length % 2 !== 0) throw new Error("invalid hex length"); const bytes = new Uint8Array(trimmed.length / 2); for (let i = 0; i < bytes.length; i += 1) { const val = Number.parseInt(trimmed.slice(i * 2, i * 2 + 2), 16); if (Number.isNaN(val)) throw new Error(`invalid hex byte at offset ${i * 2}`); bytes[i] = val; } return bytes; } /** encode raw bytes to a lowercase hexadecimal string */ export function bytesToHex(bytes: Uint8Array): string { let hex = ""; for (const b of bytes) { hex += b.toString(16).padStart(2, "0"); } return hex; } export const BITS_PER_GLYPH = 5; /** chars needed for n bits in YURL32 */ export function glyphsFor(bits: number): number { return Math.ceil(bits / BITS_PER_GLYPH); } /** YURL32 glyphs → bytes recoverable via {@link unyurl32} */ export function bytesInGlyphs(glyphs: number): number { return Math.floor((glyphs * BITS_PER_GLYPH) / 8); } /** * Structural type for a Cloudflare Worker unique ID (such as `DurableObjectId`), * matching `id.toString()` which returns the 64-hex string. */ export interface DurableObjectIdLike { toString(): string; equals?(other: unknown): boolean; readonly name?: string; } /** A Worker unique ID input: a `DurableObjectId`, 64-char hex string, or raw bytes. */ export type WorkerUniqueId = DurableObjectIdLike | Uint8Array; /** * Convert a Worker unique ID (or raw bytes) to a YURL32 webkey string. * * @param id A `DurableObjectId` (from `ns.newUniqueId()`), a 64-char hex ID, * or raw bytes (`Uint8Array`). * @returns A YURL32 webkey string (52 glyphs for 256-bit `DurableObjectId`, * 26 glyphs for 128-bit raw bytes). */ export function toWebkey(id: WorkerUniqueId | string): string { if (id instanceof Uint8Array) { return yurl32(id); } const str = typeof id === "string" ? id.trim() : id.toString(); if (/^[0-9a-fA-F]{64}$/.test(str)) { return yurl32(hexToBytes(str)); } if (isWebkey(str)) { return str; } throw new Error("cannot convert ID to webkey: unrecognized format"); } /** * Convert a YURL32 webkey string back to the 64-character hex ID string * expected by `DurableObjectNamespace.idFromString(hex)`. * * If the input is already a 64-character hex ID, it is validated and returned * in lowercase for backwards compatibility. */ export function idFromWebkey(webkey: string): string { const trimmed = webkey.trim(); if (/^[0-9a-fA-F]{64}$/.test(trimmed)) { return trimmed.toLowerCase(); } const bytes = unyurl32(trimmed); if (bytes.length !== 32) { throw new Error( `expected 32 bytes (256 bits) for Worker unique ID, got ${bytes.length}`, ); } return bytesToHex(bytes); } /** * Is `text` a well-formed webkey of the expected size? * * When `bits` is omitted, accepts standard 256-bit Worker unique ID webkeys * (52 glyphs), 128-bit webkeys (26 glyphs), or 64-bit webkeys (13 glyphs). */ export function isWebkey(text: string, bits?: number): boolean { if (bits !== undefined) { if (text.length !== glyphsFor(bits)) return false; try { return unyurl32(text).length === Math.floor(bits / 8); } catch { return false; } } if (text.length === 52 || text.length === 26 || text.length === 13) { try { const b = unyurl32(text); return b.length === 32 || b.length === 16 || b.length === 8; } catch { return false; } } return false; } /** * The identity-without-authority twin (the swissHash discipline): * SHA-256 hash of the webkey, YURL32-encoded (52 glyphs). * * Safe for logs/metrics/joins; never accepted as a credential. */ export async function shashKey(key: string | WorkerUniqueId): Promise { const text = typeof key === "string" ? key : toWebkey(key); const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); return yurl32(new Uint8Array(digest)); }