Guide

Hiding creation time and unwanted words

Two optional extensions for public TNIDs: encryption hides a time-sortable TNIDv0's creation time; blocklist filtering keeps listed words out of generated TNIDs.

Hiding creation time with encryption

A secret key turns a TNIDv0 into a TNIDv1 (random) and back. Without the key, the encrypted TNID looks like any other TNIDv1.

  1. Store TNIDv0, for index-friendly, time-ordered keys.
  2. Encrypt to TNIDv1 at every exit, not only the main API. Error messages, exports, fields that show when a record was created, and lists sorted or paginated by stored TNID reveal creation time or order.
  3. Decrypt incoming TNIDs to TNIDv0 before the lookup. Treat an incoming TNIDv0 as a bug: one leaked unencrypted.
use tnid::encryption::EncryptionKey;

let key = EncryptionKey::from_hex(key_hex)?; // 32 hex characters, from your secret store

let stored = Tnid::<User>::new_v0();          // keep this in the database
let shown = stored.encrypt_v0_to_v1(&key)?;   // show this to clients
let back = shown.decrypt_v1_to_v0(&key)?;     // same value as `stored`
import { decryptV1ToV0, EncryptionKey, encryptV0ToV1 } from "@tnid/encryption";

const key = EncryptionKey.fromHex(keyHex); // 32 hex characters, from your secret store

const stored: UserId = UserId.new_v0();         // keep this in the database
const shown = await encryptV0ToV1(stored, key); // show this to clients
const back = await decryptV1ToV0(shown, key);   // same value as `stored`
  • Different value: in both forms, the shown TNID differs from the stored one; only the name matches. A client cache or partner database joining on it holds values not in your tables.
  • Stable: the same stored TNID and key give the same shown TNID, so public links don't change.

Key handling

Treat the key as a long-lived secret: 16 random bytes from a secret store, never a passphrase or an example key.

KeyEffect
The key is lost.You can no longer decrypt shown TNIDs, so no public TNID your clients hold can be looked up.
The key leaks.Anyone with the key can read each shown TNID's creation time; rotating can't undo that.
You rotate the key.Ill-advised. Any TNIDv1 decrypts under any key, so nothing shows which key, if any, encrypted it. Old shown TNIDs decrypt to a wrong TNIDv0 without error; the only sign is a failed lookup, as for a TNID that never existed. Keep one key.

If an unencrypted TNID leaks

Key compromised? No: recovering the key costs 2¹²⁸ guesses whether one TNIDv0 leaked with its TNIDv1 form or a billion did. Any shown TNID already lets an attacker test a guessed key: the decrypted timestamp must fall within your app's lifetime.

EventExposesWhat to do
A stored TNIDv0 leaks.That one TNID's creation time becomes public for good. No other TNID is affected.Nothing. That time can't be hidden again, and rotating the key only breaks lookups.
Your key is weak, such as a passphrase.Anyone who guesses the key can read every shown TNID's creation time, with no leak needed.Prevent it: make the key as described in Key handling. Replacing it later is a rotation.
An endpoint decrypts an incoming TNID and answers with something derived from it, such as its creation time or its place in a sorted list.Anyone can learn the creation time or order of any TNIDv1 they send, including ones they made up.Treat those answers as exits, as in step 2 above.

Takeaway: a leak publishes one TNID's creation time, nothing more. Guard key randomness and endpoints; rotating the key is no remedy.

Keeping unwanted words out

The 17 characters after the dot (both letter cases, digits, -, _) can spell words by chance. Filtering keeps your list's words out of them; the name isn't checked, and there is no built-in list.

  • Case-insensitive: blocking TACO also keeps out taco and TaCo.
  • TNIDv0 time can move forward to avoid a word in the timestamp, so it can read later than the real creation time. Keep a separate timestamp column that records when each row was created.
  • Bounded: generation gives up with an error after a set number of attempts.
  • With encryption, use the encryption-aware function below; it checks both the stored and the shown TNID.
use tnid::filter::Blocklist;

let blocklist = Blocklist::new(&["TACO", "FOO"])?;

let id = Tnid::<User>::new_v0_filtered(&blocklist)?;
import { Blocklist, newV0Filtered } from "@tnid/filter";

const blocklist = new Blocklist(["TACO", "FOO"]);

const id: UserId = newV0Filtered(UserId, blocklist);

Library support

  • Rust: encryption and filter features of the tnid crate; encryption-aware filter new_v0_filtered_for_encryption.
  • TypeScript: @tnid/encryption and @tnid/filter; encryption-aware filter newV0FilteredForEncryption from @tnid/filter/encryption.

Other libraries: Libraries. How the extensions work: Encryption, Blocklist filtering.