Signed Timestamp codec
The Signed Timestamp codec keeps the 48-bit timestamp readable and sortable like the Timestamp codec, but replaces half of the random tail with a truncated HMAC tag — making IDs tamper-evident and verifiable without a database lookup. This adds integrity, not confidentiality — the opposite security axis from the Opaque codec.
The canonical use case is share links: embed a Signed Timestamp ID in a URL and verify it on receipt without a database roundtrip.
import { createSignedTimestampId, importSigningKey } from "@smonn/ids/signed";
const key = await importSigningKey(new Uint8Array(32));const shares = createSignedTimestampId("shr", { keys: [key] });
const id = await shares.generate(); // "shr_…", timestamp readable and sortableshares.extractTimestamp(id); // Date — sync, timestamp is plaintext
await shares.verify(id); // passes; throws IdsError verification_failed on tampergenerate, generateAt, verify, and safeVerify are async (WebCrypto). is, parse,
safeParse, extractTimestamp, minIdForTime, maxIdForTime, and
toJsonSchema stay sync — they work on the wire form only
(ADR-0006).
Verifying untrusted input
Section titled “Verifying untrusted input”safeVerify accepts untrusted input, structurally parses first, then verifies —
without throwing:
const result = await shares.safeVerify(req.params.shareId);
if (!result.ok) { if (result.error === "verification_failed") return 403; // tampered or wrong key return 400; // malformed ID}
const { id } = result; // Id<"shr">, canonicalIt returns { ok: true, id }, a structural parse error
(not_string | invalid_prefix | invalid_base32), or verification_failed for a
tag mismatch.
verify and extractTimestamp trust contracts
Section titled “verify and extractTimestamp trust contracts”verify(id: Id<Brand>) trusts the Id<Brand> static type and does not
structurally validate. For untrusted input, route through safeVerify or
safeParse first:
// Untrusted input — use safeVerify (or safeParse then verify)const result = await shares.safeVerify(req.params.shareId);if (!result.ok) { if (result.error === "verification_failed") return 403; return 400;}// result.id is a verified Id<"shr"> — safe to use// Already-typed Id<"shr"> — verify trusts the typeconst id = await shares.generate(); // or from a prior safeParse / safeVerifyawait shares.verify(id); // throws IdsError verification_failed on tag mismatchextractTimestamp(id: Id<Brand>) reads the plaintext timestamp bytes without
verifying the HMAC tag. A tampered ID returns a timestamp without raising an
error — the decoded bytes are structurally valid milliseconds regardless of
integrity. Always verify first if the source is untrusted:
const result = await shares.safeVerify(req.params.shareId);if (!result.ok) { if (result.error === "verification_failed") return 403; return 400;}
// Safe: id was verified before extractTimestamp is calledconst ts = shares.extractTimestamp(result.id);Constructor options
Section titled “Constructor options”createSignedTimestampId(brand, opts) accepts:
| Option | Type | Default | Purpose |
|---|---|---|---|
keys |
[SigningKey, ...SigningKey[]] |
(required) | Non-empty ordered signing keyring |
now |
() => number |
Date.now |
Returns the current timestamp in milliseconds; inject in tests to control time |
rng |
(target: Uint8Array) => void |
crypto.getRandomValues |
Writes 5 random bytes into target for the random tail; inject in tests for deterministic output |
allowDuplicateBrand |
boolean |
false |
Silences the duplicate-brand warning in non-production environments (e.g. for holding multiple codec instances during signing keyring transition tests) |
Testing
Section titled “Testing”Inject a fixed now, a no-op rng, and a signing key from constant bytes for
reproducible IDs; generate and verify are async. See the
Testing guide for the full pattern.
Key handling
Section titled “Key handling”Import signing key material via importSigningKey(bytes) from raw bytes (16,
24, or 32 bytes). SigningKey is an opaque handle — the underlying non-extractable CryptoKey
and a SHA-256 digest of the raw import bytes are held in a module-internal
WeakMap and never exposed to callers. The digest backs constant-time
duplicate-keyring detection; the raw bytes are not retained after import.
Signing-key material is a separate secret domain from Opaque and Wrapping
keys — same hex / base64url encoding conventions, but a distinct SigningKey
handle and HKDF label, so one raw secret cannot silently serve multiple codecs.
import { encodeSigningKey, decodeSigningKey } from "@smonn/ids/signed";
const encoded = encodeSigningKey(rawBytes, "base64url"); // stringconst decoded = decodeSigningKey(encoded, "base64url"); // Uint8ArrayKeyring rotation
Section titled “Keyring rotation”Pass a non-empty ordered list of signing keys. The first entry is the current
key — the only one generate / generateAt sign with. verify / safeVerify
trial every entry in order until the tag matches, so IDs signed under any listed
key remain verifiable. Removing an entry revokes all IDs signed under it —
revocation takes effect by constructing a new codec instance with the
reduced keys list; in-place mutation of the original array has no effect.
const oldKey = await importSigningKey(rawOldSecret);const newKey = await importSigningKey(rawNewSecret);
// After rotation: newKey is current; oldKey is still accepted on verifyconst rotated = createSignedTimestampId("shr", { keys: [newKey, oldKey] });await rotated.verify(id); // succeeds — tried oldKey and matchedawait rotated.generate(); // signs with newKeySentinels from minIdForTime / maxIdForTime carry no valid HMAC tag — they
exist only for indexed range scans, not as real IDs. See
ADR-0012.
Error handling
Section titled “Error handling”All errors are IdsError instances with a stable code field. Use isIdsError
to discriminate them — import it from @smonn/ids:
import { createSignedTimestampId, importSigningKey } from "@smonn/ids/signed";import { isIdsError } from "@smonn/ids";Construction errors — thrown by createSignedTimestampId:
| Code | Thrown when |
|---|---|
empty_keyring |
keys array is empty |
duplicate_keyring_entry |
Two entries in keys share the same raw secret |
Key helper errors:
| Code | Thrown when | Function |
|---|---|---|
invalid_key_length |
Raw key bytes are not 16, 24, or 32 bytes | encodeSigningKey, decodeSigningKey; rejected by importSigningKey |
invalid_key_format |
format argument is not "hex" or "base64url" |
encodeSigningKey, decodeSigningKey |
invalid_key_encoding |
Encoded string is malformed for its format; err.cause holds the original decode Error |
decodeSigningKey |
Verification error — thrown by verify:
| Code | Thrown when |
|---|---|
verification_failed |
No keyring entry’s HMAC tag matches the ID |
Example error-handling pattern:
try { await shares.verify(id);} catch (err) { if (isIdsError(err) && err.code === "verification_failed") { // tampered ID or wrong keyring } throw err;}Construction errors (createSignedTimestampId) and encodeSigningKey/decodeSigningKey errors are thrown synchronously — for example, passing an empty keys array throws immediately with code empty_keyring. importSigningKey rejects with invalid_key_length only — always await the call and catch the rejection.