Files
captoys/diigo-cache/src/webkeys.ts
T
dckc 11c4583a17 fixup! diigo-cache: bookmark cache in Durable Objects (goal 1c)
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
2026-10-07 09:34:06 -05:00

193 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** @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));
}