/** @file webkeys: URL-borne bearer keys, YURL32-encoded; see {@link toWebkey}. */ /** 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. * * A webkey separates secret from address. The secret bits are supplied by * the caller (a `DurableObjectId` from `ns.newUniqueId()`, or raw bytes): * these functions are pure, with no ambient RNG; the caller holds the * minting authority. The encoding is YURL32: lowercase `a–z` + `2–7`, * 5 bits per glyph, URL-safe by construction. A 256-bit `DurableObjectId` * (64 hex characters) becomes 52 glyphs; {@link idFromWebkey} decodes it * back for `ns.idFromString()`. * * Provenance: Tyler Close's web-key (IEEE W2SP 2008) and the Waterken code * in `kpreid/e-on-java` (YURL32 = `org.waterken.uri.Base32`); the design * abstract is docs/webkeys.md. * * @see https://waterken.sourceforge.net/web-key/ * * @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)); }