Skip to content

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 sortable
shares.extractTimestamp(id); // Date — sync, timestamp is plaintext
await shares.verify(id); // passes; throws IdsError verification_failed on tamper

generate, 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).

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">, canonical

It 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 type
const id = await shares.generate(); // or from a prior safeParse / safeVerify
await shares.verify(id); // throws IdsError verification_failed on tag mismatch

extractTimestamp(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 called
const ts = shares.extractTimestamp(result.id);

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)

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.

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"); // string
const decoded = decodeSigningKey(encoded, "base64url"); // Uint8Array

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 verify
const rotated = createSignedTimestampId("shr", { keys: [newKey, oldKey] });
await rotated.verify(id); // succeeds — tried oldKey and matched
await rotated.generate(); // signs with newKey

Sentinels from minIdForTime / maxIdForTime carry no valid HMAC tag — they exist only for indexed range scans, not as real IDs. See ADR-0012.

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.