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.
- Store TNIDv0, for index-friendly, time-ordered keys.
- 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.
- 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.
| Key | Effect |
|---|---|
| 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.
| Event | Exposes | What 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
TACOalso keeps outtacoandTaCo. - 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:
encryptionandfilterfeatures of thetnidcrate; encryption-aware filternew_v0_filtered_for_encryption. - TypeScript:
@tnid/encryptionand@tnid/filter; encryption-aware filternewV0FilteredForEncryptionfrom@tnid/filter/encryption.
Other libraries: Libraries. How the extensions work: Encryption, Blocklist filtering.