Docs per the docs-files-api skill: a 1–2 line @file; what a declaration is or does in JSDoc on it, not in // comments or the file header. Generated-by: Claude Opus 5.5
193 lines
6.0 KiB
TypeScript
193 lines
6.0 KiB
TypeScript
/** @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<string> {
|
||
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));
|
||
}
|