Opaque Timestamp codec
The Timestamp codec exposes the creation time by design.
If that’s a leak you can’t accept — invoice IDs revealing billing cadence,
signup IDs revealing acquisition velocity — the Opaque Timestamp codec keeps the
same <brand>_<26 chars> wire shape but AES-encrypts the payload under a key
you supply. This adds confidentiality, not integrity — the opposite axis
from the Signed Timestamp codec.
import { createOpaqueTimestampId, importOpaqueKey } from "@smonn/ids/opaque";
const key = await importOpaqueKey(new Uint8Array(16)); // an OpaqueKey handleconst invoices = createOpaqueTimestampId("inv", { key });
const id = await invoices.generate(); // "inv_…", timestamp not extractable without the keyawait invoices.extractTimestamp(id); // Date — same key requiredImportant — unauthenticated decryption:
extractTimestampnever throws on a wrong or tampered key. A mismatched key decrypts silently to a plausible but incorrectDate. This is the opposite of the Signed Timestamp codec, whoseverifythrowsverification_failedon tag mismatch. If you need tamper-evident IDs, use the Signed Timestamp codec instead.
importOpaqueKey
Section titled “importOpaqueKey”importOpaqueKey(bytes) is async and returns an opaque OpaqueKey handle —
not a raw CryptoKey. The underlying CryptoKey is held internally and is never
exposed to callers.
The bytes are HKDF input keying material, not the AES key itself: the codec
derives an AES-256 key from them via HKDF under the label
@smonn/ids/opaque/aes (ADR-0027).
Accepts 16, 24, or 32 bytes; the input size sets the entropy floor only —
every handle yields AES-256, and a 16-byte handle carries a 128-bit entropy
floor:
const key128 = await importOpaqueKey(new Uint8Array(16)); // AES-256, 128-bit entropy floorconst key192 = await importOpaqueKey(new Uint8Array(24)); // AES-256, 192-bit entropy floorconst key256 = await importOpaqueKey(new Uint8Array(32)); // AES-256, 256-bit entropy floorAny other byte length throws invalid_key_length.
Because the key is HKDF-derived under a codec-specific label, the same raw secret may safely serve every keyed codec — a primary secret. Each codec still needs its own explicit import.
Storing key material
Section titled “Storing key material”encodeOpaqueKey / decodeOpaqueKey round-trip raw bytes to and from hex or
base64url strings for storage in env vars or secret managers. The format
argument is required and must match between encode and decode:
import { encodeOpaqueKey, decodeOpaqueKey } from "@smonn/ids/opaque";
const raw = new Uint8Array(32); // 32 bytes of key materialconst encoded = encodeOpaqueKey(raw, "hex"); // "0000…" (64 hex chars)const decoded = decodeOpaqueKey(encoded, "hex"); // back to Uint8Array// decoded is identical to raw
// base64url is also valid:const b64 = encodeOpaqueKey(raw, "base64url");const raw2 = decodeOpaqueKey(b64, "base64url");The CLI keygen command emits keys in this format.
decodeOpaqueKey throws IdsError with code: "invalid_key_encoding" when the string is malformed for its format (bad hex digits, non-canonical base64url); err.cause holds the original decode Error with the specific reason. See the error-code reference for the full invalid_key_encoding entry.
generateAt validation
Section titled “generateAt validation”generateAt(date) rejects invalid input and throws an IdsError with
code: "invalid_timestamp":
- negative timestamp —
date.getTime() < 0 - non-integer timestamp —
date.getTime()is a float (e.g.1.5) - timestamp exceeds 48-bit range —
date.getTime() >= 2 ** 48 Invalid Date—date.getTime()isNaN
Use isIdsError from @smonn/ids to catch it — instanceof Error alone matches
but does not discriminate the code:
import { isIdsError } from "@smonn/ids";
try { const id = await invoices.generateAt(date);} catch (err) { if (isIdsError(err) && err.code === "invalid_timestamp") { // date is invalid — negative, non-integer, out of range, or Invalid Date } throw err;}Testing
Section titled “Testing”Inject a fixed now, a no-op rng, and a key from constant bytes for
reproducible ciphertext; generate and extractTimestamp are async. See the
Testing guide for the full pattern.
Differences from the Timestamp codec
Section titled “Differences from the Timestamp codec”- Async key-dependent methods. WebCrypto is async-only, so
generate,generateAt, andextractTimestampreturnPromises.is,parse,safeParse,toJsonSchema, and the Standard Schema adapter stay sync — they work on the wire form only. - No
minIdForTime/maxIdForTime. Encrypted payloads don’t sort by time. Store the timestamp in a separate column if you need time-range scans. - Wire-indistinguishable from the Timestamp codec. Codec choice is a per-brand commitment.
Encryption is AES-CBC with a zero IV — deliberately safe here because the plaintext already carries 80 bits of entropy per ID (ADR-0004).
Rotating the Opaque key
Section titled “Rotating the Opaque key”Rotation is forward-only and caller-tracked — the codec deliberately has no
key ring. The key feeds only generate and extractTimestamp; parse,
safeParse, is, and toJsonSchema never touch it, so rotating forward is
nearly free: point new writes at a new key and keep the old key only to read old
IDs’ timestamps.
Because the payload is unauthenticated and carries no key id, the library cannot trial a ring to pick the right key — a wrong key yields a plausible but wrong timestamp, never an error. You hold one codec per key epoch and select it from your own records.
When two codec instances share the same brand (as in the multi-epoch pattern
below), pass allowDuplicateBrand: true on every non-current (retired) instance.
This suppresses the dev-only cross-codec warning that fires when multiple codec
instances register the same brand — a warning that normally signals a mistake,
but here is intentional:
// Import raw key bytes and produce OpaqueKey handles first.const keyV1 = await importOpaqueKey(new Uint8Array(16).fill(0x01)); // retired keyconst keyV2 = await importOpaqueKey(new Uint8Array(16).fill(0x02)); // current key
// One codec instance per key epoch. You — not the library — track which epoch// minted each ID. The epoch CANNOT be read from the ID itself.const codecs = new Map([ [1, createOpaqueTimestampId("inv", { key: keyV1, allowDuplicateBrand: true })], // retired [2, createOpaqueTimestampId("inv", { key: keyV2 })], // current — no flag needed]);
const id = await codecs.get(2)!.generate(); // new IDs use the current epoch's key
// Reading an old ID: look up its epoch from your records, pick that codec.const epoch = await db.keyEpochFor(someOldId);await codecs.get(epoch)!.extractTimestamp(someOldId);If you need transparent, correctness-grade rotation where a wrong key is rejected, that’s the Signed Timestamp codec’s job — its HMAC tag gives a verifiable ring. The Opaque codec trades that away for confidentiality. See ADR-0013.