Skip to content

Digest codec

The Digest codec maps caller material to a stable public ID under one Digest key. The same material always yields the same ID; the material cannot be recovered from the ID. It is designed for idempotency keys, content-addressed records, and stable public pseudonyms.

import { createDigestId, importDigestKey } from "@smonn/ids/digest";
const key = await importDigestKey(new Uint8Array(32));
const idk = createDigestId("idk", { ns: "checkout", key });
const id = await idk.digest("order-ref-123"); // Id<"idk">
const id2 = await idk.digest("order-ref-123"); // same Id<"idk">

digest is async (WebCrypto HMAC). is, parse, safeParse, toJsonSchema, and ~standard are structural and sync — they validate prefix and base32 shape only, no key required.

The same (brand, ns, key, material) tuple always returns the same ID. An observer without the key can tell that two identical public IDs come from the same material — but cannot recover that material from the wire form alone. This is intentional: it is what makes idempotency keys and content addressing work.

There is no reverse method — no unwrap, verify, or extractTimestamp. The codec is one-way by definition. To check whether material matches a known ID, re-digest the material and compare IDs directly.

ns is a required, non-empty, construction-time string mixed into every digest. The same material under a different ns yields a completely different ID, so one key can serve multiple unlinkable namespaces without any correlation:

const emailIds = createDigestId("uid", { ns: "email-pseudonym", key });
const ticketIds = createDigestId("uid", { ns: "support-ticket", key, allowDuplicateBrand: true });
const emailId = await emailIds.digest("user@example.com"); // Id<"uid">
const ticketId = await ticketIds.digest("user@example.com"); // different Id<"uid">

ns is not on the wire — the brand prefixes the ID, but ns is folded into the digest and never appears in the encoded string. This is what allows two domains to share a visible brand while remaining unlinkable.

An empty or whitespace-only ns throws IdsError with code "invalid_namespace" at construction.

digest(material) accepts string | Uint8Array. Strings are UTF-8 encoded; byte arrays are used as-is. The codec does not accept or canonicalise structured objects — callers canonicalise their own data before passing a string or bytes.

const str = await idk.digest("hello world");
const bytes = await idk.digest(new TextEncoder().encode("hello world"));
// str === bytes — same ID

Material may be any length — there is no enforced cap; HMAC streams arbitrary-length input, and cost scales with material size.

The Digest codec holds exactly one key — there is no keyring. Two reasons:

  • No tag to trial. The entire 16-byte payload is the one-way output; there is nothing embedded to test a candidate key against.
  • Rotation breaks the contract. The whole value proposition is a stable-forever map. Rotating to a new key would silently change every future ID for unchanged material, breaking idempotency and content-address stability.

Re-keying is a deliberate, breaking operator action — every ID changes. Any key change makes all previously issued IDs unreproducible; stored IDs must be re-derived and back-filled.

The Digest codec is deterministic by construction — no now or rng to control. Import a key from constant bytes and the same (ns, material) always produces the same ID (digest is async). See the Testing guide for the full pattern.

  • 128-bit payload, birthday bound ≈ 2⁶⁴. The payload is the leftmost 16 bytes of HMAC-SHA-256. An accidental same-ID-for-different-material collision requires on the order of 2⁶⁴ distinct inputs within one (brand, ns) space — ample for idempotency keys, content addressing, and pseudonyms.
  • Key secrecy is load-bearing. Without the key, an observer cannot brute-force even very low-entropy material. With the key, low-entropy material is brute-forceable. The Digest codec provides confidentiality of material only through key secrecy; it is not a substitute for protecting low-entropy material against an adversary who holds the key.

importDigestKey is async. Import digest key material from raw bytes (16 / 24 / 32 raw bytes — fed to HKDF to derive an HMAC-SHA-256 key):

import { importDigestKey, encodeDigestKey, decodeDigestKey } from "@smonn/ids/digest";
// Generate a new key
const raw = crypto.getRandomValues(new Uint8Array(32));
const encoded = encodeDigestKey(raw, "hex"); // store in env / secret manager
const decoded = decodeDigestKey(encoded, "hex");
const key = await importDigestKey(decoded);

encodeDigestKey / decodeDigestKey support "hex" (lowercase) and "base64url" formats. The DigestKey handle holds a single HMAC-SHA-256 subkey derived via HKDF under the domain label @smonn/ids/digest/hmac — cryptographically independent from any OpaqueKey, WrappingKey, or SigningKey derived from the same raw bytes.

Code When
invalid_brand Brand is not three lowercase a–z characters
invalid_namespace ns is empty or whitespace-only
invalid_key_length Raw key bytes are not 16, 24, or 32 bytes
invalid_key_format Format passed to encode/decode is not "hex" or "base64url"
invalid_key_encoding Encoded key string is malformed for its format; err.cause holds the original decode Error
invalid_id Thrown by parse on structural failure; safeParse returns { ok: false, error: … } instead of throwing. See the error-code reference for the shared error reference.

is, parse, and safeParse validate prefix and base32 shape only — no key required:

idk.is(id); // true only for canonical Id<"idk"> strings
idk.parse(rawInput); // canonical Id<"idk"> or throws IdsError invalid_id
const result = idk.safeParse(rawInput);
// { ok: true, id } | { ok: false, error: "not_string" | "invalid_prefix" | "invalid_base32" }

safeParse accepts Crockford visual aliases (o → 0, i → 1, l → 1) and returns the canonical lowercase form. It rejects IDs whose final base32 character has non-zero padding bits (code "invalid_base32").

The ~standard schema integration shape is documented in the validation page.