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.
Determinism and equality leakage
Section titled “Determinism and equality leakage”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.
The ns namespace
Section titled “The ns namespace”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.
Material types
Section titled “Material types”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 IDMaterial may be any length — there is no enforced cap; HMAC streams arbitrary-length input, and cost scales with material size.
Single key, no keyring
Section titled “Single key, no keyring”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.
Testing
Section titled “Testing”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.
Security posture
Section titled “Security posture”- 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.
Key handling
Section titled “Key handling”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 keyconst raw = crypto.getRandomValues(new Uint8Array(32));const encoded = encodeDigestKey(raw, "hex"); // store in env / secret managerconst 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.
Errors
Section titled “Errors”| 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. |
Wire methods (sync, no key)
Section titled “Wire methods (sync, no key)”is, parse, and safeParse validate prefix and base32 shape only — no key
required:
idk.is(id); // true only for canonical Id<"idk"> stringsidk.parse(rawInput); // canonical Id<"idk"> or throws IdsError invalid_idconst 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.