Skip to main content

vault_core/
uid_registry.rs

1//! The unique-ID registry — Phase 24.4.
2//!
3//! A server-side record of every identifier this deployment has issued, so a
4//! new one can be checked for uniqueness before it is handed out and a
5//! presented one can be checked for provenance. **Keyed hash only, never the
6//! value**: a database of every API key ever minted would be the best single
7//! target an attacker could find, so what is stored is
8//! `HMAC-SHA256(pepper, normalise(value))` truncated to 16 bytes — enough to be
9//! collision-free at any size this will reach, and useless if stolen.
10//!
11//! Lives in its own SQLCipher file, `registry.db`, beside `vault.db`. Its key
12//! and pepper are random 32-byte values held in the **vault's** `vault_meta`
13//! table: the registry only opens while the vault is unlocked, travels with
14//! vault backups, and survives a master-password change without needing its
15//! own KDF.
16//!
17//! Storage shape and pragmas are exactly what was measured on 2026-09-14
18//! (`CLAUDE.md`, Phase 24.4): `WITHOUT ROWID`, a date index, a 32 MB page
19//! cache, and pruning in 10,000-row chunks rather than one statement — the
20//! single-statement prune held the writer lock for 45.7s at 10M rows in that
21//! benchmark, which would stall every mint and register behind it.
22
23use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
24use serde::Serialize;
25use sha2::Sha256;
26use std::fs;
27use std::path::Path;
28
29/// A value is registered by an `INSERT` that fails on the primary key, never a
30/// `SELECT` then `INSERT` — so two concurrent mints of one value cannot both
31/// succeed. `check` is therefore advisory ("unique right now"); `register` is
32/// authoritative.
33const PRUNE_CHUNK: usize = 10_000;
34
35// ── Registry secrets, held in the vault's own vault_meta ──────────────────────
36
37const META_KEY: &str = "uid_registry_key";
38const META_PEPPER: &str = "uid_registry_pepper";
39
40fn rand32() -> [u8; 32] {
41    use rand::RngCore;
42    let mut b = [0u8; 32];
43    rand::thread_rng().fill_bytes(&mut b);
44    b
45}
46
47/// Reads the registry's SQLCipher key and HMAC pepper from `vault_meta`,
48/// generating and persisting both the first time the registry is used. Callers
49/// never see the values live anywhere but here and inside the open registry
50/// connection.
51pub fn ensure_registry_secrets(vault_conn: &Connection) -> Result<([u8; 32], [u8; 32]), String> {
52    let read = |key: &str| -> Result<Option<String>, String> {
53        vault_conn
54            .query_row(
55                "SELECT value FROM vault_meta WHERE key = ?1",
56                params![key],
57                |r| r.get::<_, String>(0),
58            )
59            .optional()
60            .map_err(|e| e.to_string())
61    };
62    let write = |key: &str, value: &str| -> Result<(), String> {
63        vault_conn
64            .execute(
65                "INSERT OR REPLACE INTO vault_meta (key, value) VALUES (?1, ?2)",
66                params![key, value],
67            )
68            .map(|_| ())
69            .map_err(|e| e.to_string())
70    };
71
72    let key = match read(META_KEY)? {
73        Some(hex_str) => {
74            let bytes = hex::decode(&hex_str).map_err(|e| e.to_string())?;
75            bytes
76                .try_into()
77                .map_err(|_| "uid_registry_key is corrupt (wrong length)".to_string())?
78        }
79        None => {
80            let k = rand32();
81            write(META_KEY, &hex::encode(k))?;
82            k
83        }
84    };
85    let pepper = match read(META_PEPPER)? {
86        Some(hex_str) => {
87            let bytes = hex::decode(&hex_str).map_err(|e| e.to_string())?;
88            bytes
89                .try_into()
90                .map_err(|_| "uid_registry_pepper is corrupt (wrong length)".to_string())?
91        }
92        None => {
93            let p = rand32();
94            write(META_PEPPER, &hex::encode(p))?;
95            p
96        }
97    };
98    Ok((key, pepper))
99}
100
101// ── Opening the registry file ──────────────────────────────────────────────────
102
103/// Opens (creating if absent) `registry.db` at `path` with the measured pragmas.
104pub fn open_registry(path: &Path, key: &[u8; 32]) -> Result<Connection, String> {
105    if let Some(p) = path.parent() {
106        fs::create_dir_all(p).map_err(|e| e.to_string())?;
107    }
108    let conn = Connection::open_with_flags(
109        path,
110        OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
111    )
112    .map_err(|e| e.to_string())?;
113    conn.execute_batch(&format!("PRAGMA key = \"x'{}'\";", hex::encode(key)))
114        .map_err(|e| e.to_string())?;
115    conn.execute_batch("SELECT count(*) FROM sqlite_master;")
116        .map_err(|_| "Wrong registry key".to_string())?;
117    conn.execute_batch(
118        "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA cache_size=-32000;",
119    )
120    .map_err(|e| e.to_string())?;
121    crate::restrict_to_owner(path)?;
122    for suffix in ["-wal", "-shm"] {
123        let mut side = path.as_os_str().to_owned();
124        side.push(suffix);
125        let side = std::path::PathBuf::from(side);
126        if side.exists() {
127            crate::restrict_to_owner(&side)?;
128        }
129    }
130    init_schema(&conn)?;
131    Ok(conn)
132}
133
134pub fn init_schema(conn: &Connection) -> Result<(), String> {
135    conn.execute_batch(
136        "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;
137         CREATE TABLE IF NOT EXISTS uid (
138             h BLOB PRIMARY KEY,
139             t INTEGER NOT NULL,
140             m INTEGER
141         ) WITHOUT ROWID;
142         CREATE INDEX IF NOT EXISTS uid_t ON uid(t);
143         CREATE TABLE IF NOT EXISTS uid_meta (
144             id        INTEGER PRIMARY KEY,
145             namespace TEXT,
146             generator TEXT,
147             params    TEXT,
148             normalise TEXT,
149             actor     TEXT,
150             source    TEXT,
151             entry_ck  TEXT,
152             note      TEXT,
153             created_at INTEGER NOT NULL
154         );",
155    )
156    .map_err(|e| e.to_string())
157}
158
159// ── Normalisation ────────────────────────────────────────────────────────────
160
161#[derive(Clone, Copy, PartialEq, Eq, Debug)]
162pub enum Normalise {
163    Uuid,
164    Ulid,
165    Lower,
166    None,
167}
168
169impl Normalise {
170    pub fn parse(s: &str) -> Option<Self> {
171        match s.to_ascii_lowercase().as_str() {
172            "uuid" => Some(Normalise::Uuid),
173            "ulid" => Some(Normalise::Ulid),
174            "lower" => Some(Normalise::Lower),
175            "none" => Some(Normalise::None),
176            _ => None,
177        }
178    }
179
180    pub fn as_str(self) -> &'static str {
181        match self {
182            Normalise::Uuid => "uuid",
183            Normalise::Ulid => "ulid",
184            Normalise::Lower => "lower",
185            Normalise::None => "none",
186        }
187    }
188}
189
190/// Decides what "the same ID" means. Getting this wrong makes two spellings of
191/// one identifier both look unique.
192pub fn normalise(value: &str, mode: Normalise) -> String {
193    match mode {
194        Normalise::Uuid => {
195            let hex_only: String = value
196                .chars()
197                .filter(|c| c.is_ascii_hexdigit())
198                .collect::<String>()
199                .to_ascii_lowercase();
200            if hex_only.len() == 32 {
201                format!(
202                    "{}-{}-{}-{}-{}",
203                    &hex_only[0..8],
204                    &hex_only[8..12],
205                    &hex_only[12..16],
206                    &hex_only[16..20],
207                    &hex_only[20..32]
208                )
209            } else {
210                value.to_ascii_lowercase()
211            }
212        }
213        Normalise::Ulid => value
214            .to_ascii_uppercase()
215            .chars()
216            .map(|c| match c {
217                'I' | 'L' => '1',
218                'O' => '0',
219                other => other,
220            })
221            .collect(),
222        Normalise::Lower => value.to_ascii_lowercase(),
223        Normalise::None => value.to_string(),
224    }
225}
226
227/// `HMAC-SHA256(pepper, normalise(value))`, truncated to 16 bytes. The
228/// namespace is **not** part of the input — that is what "unique across
229/// everything" means: the same string minted under two namespaces is one
230/// collision, not two independent slots.
231pub fn hash_value(pepper: &[u8; 32], value: &str, mode: Normalise) -> [u8; 16] {
232    use hmac::{Hmac, Mac};
233    type HmacSha256 = Hmac<Sha256>;
234    let normalised = normalise(value, mode);
235    let mut mac = HmacSha256::new_from_slice(pepper).expect("HMAC accepts any key length");
236    mac.update(normalised.as_bytes());
237    let full = mac.finalize().into_bytes();
238    let mut out = [0u8; 16];
239    out.copy_from_slice(&full[..16]);
240    out
241}
242
243fn now_ts() -> i64 {
244    std::time::SystemTime::now()
245        .duration_since(std::time::UNIX_EPOCH)
246        .map(|d| d.as_secs() as i64)
247        .unwrap_or(0)
248}
249
250// ── Batch metadata ────────────────────────────────────────────────────────────
251
252#[derive(Default, Clone)]
253pub struct BatchMeta<'a> {
254    pub namespace: Option<&'a str>,
255    pub generator: Option<&'a str>,
256    pub params: Option<&'a str>,
257    pub actor: Option<&'a str>,
258    pub source: Option<&'a str>,
259    pub entry_ck: Option<&'a str>,
260    pub note: Option<&'a str>,
261}
262
263fn insert_meta_row(
264    conn: &Connection,
265    meta: &BatchMeta,
266    normalise_mode: Normalise,
267) -> Result<i64, String> {
268    conn.execute(
269        "INSERT INTO uid_meta (namespace, generator, params, normalise, actor, source, entry_ck, note, created_at) \
270         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
271        params![
272            meta.namespace,
273            meta.generator,
274            meta.params,
275            normalise_mode.as_str(),
276            meta.actor,
277            meta.source,
278            meta.entry_ck,
279            meta.note,
280            now_ts(),
281        ],
282    )
283    .map_err(|e| e.to_string())?;
284    Ok(conn.last_insert_rowid())
285}
286
287// ── Check / register / lookup ─────────────────────────────────────────────────
288
289/// Advisory only — "unique right now". Never authoritative: `register` is the
290/// only operation that actually reserves a value.
291pub fn check(
292    conn: &Connection,
293    pepper: &[u8; 32],
294    values: &[String],
295    mode: Normalise,
296) -> Result<Vec<(String, bool)>, String> {
297    let mut out = Vec::with_capacity(values.len());
298    for v in values {
299        let h = hash_value(pepper, v, mode);
300        let exists: bool = conn
301            .query_row(
302                "SELECT EXISTS(SELECT 1 FROM uid WHERE h = ?1)",
303                params![h.as_slice()],
304                |r| r.get(0),
305            )
306            .map_err(|e| e.to_string())?;
307        out.push((v.clone(), !exists));
308    }
309    Ok(out)
310}
311
312pub struct RegisterOutcome {
313    pub value: String,
314    pub registered: bool,
315}
316
317/// Registers every value as one batch. Each is an `INSERT` that fails on the
318/// primary key — never a read-then-write — so two concurrent registrations of
319/// one value cannot both succeed; the loser is reported as a conflict, not an
320/// error.
321pub fn register(
322    conn: &Connection,
323    pepper: &[u8; 32],
324    values: &[String],
325    mode: Normalise,
326    meta: &BatchMeta,
327) -> Result<Vec<RegisterOutcome>, String> {
328    let meta_id = insert_meta_row(conn, meta, mode)?;
329    let t = now_ts();
330    let mut out = Vec::with_capacity(values.len());
331    for v in values {
332        let h = hash_value(pepper, v, mode);
333        let res = conn.execute(
334            "INSERT INTO uid (h, t, m) VALUES (?1, ?2, ?3)",
335            params![h.as_slice(), t, meta_id],
336        );
337        out.push(RegisterOutcome {
338            value: v.clone(),
339            registered: res.is_ok(),
340        });
341    }
342    Ok(out)
343}
344
345/// Generate-check-register in one call, retrying up to `max_attempts` times on
346/// collision. `generate` is supplied by the caller (`unv-server`), which owns
347/// the id-shape decision this module has no opinion about.
348pub fn mint(
349    conn: &Connection,
350    pepper: &[u8; 32],
351    mode: Normalise,
352    meta: &BatchMeta,
353    max_attempts: u32,
354    mut generate: impl FnMut() -> String,
355) -> Result<Option<String>, String> {
356    for _ in 0..max_attempts.max(1) {
357        let candidate = generate();
358        let outcome = register(conn, pepper, std::slice::from_ref(&candidate), mode, meta)?;
359        if outcome.first().is_some_and(|o| o.registered) {
360            return Ok(Some(candidate));
361        }
362    }
363    Ok(None)
364}
365
366#[derive(Serialize)]
367pub struct LookupResult {
368    pub issued: bool,
369    pub namespace: Option<String>,
370    pub generator: Option<String>,
371    pub actor: Option<String>,
372    pub source: Option<String>,
373    pub created_at: Option<i64>,
374}
375
376/// One value → provenance the caller may see. `check`/`lookup` are the
377/// enumeration oracle for a low-entropy namespace — the rate limiter is the
378/// control, not this function.
379pub fn lookup(
380    conn: &Connection,
381    pepper: &[u8; 32],
382    value: &str,
383    mode: Normalise,
384) -> Result<LookupResult, String> {
385    let h = hash_value(pepper, value, mode);
386    let row = conn
387        .query_row(
388            "SELECT u.t, m.namespace, m.generator, m.actor, m.source \
389             FROM uid u LEFT JOIN uid_meta m ON u.m = m.id \
390             WHERE u.h = ?1",
391            params![h.as_slice()],
392            |r| {
393                Ok((
394                    r.get::<_, i64>(0)?,
395                    r.get::<_, Option<String>>(1)?,
396                    r.get::<_, Option<String>>(2)?,
397                    r.get::<_, Option<String>>(3)?,
398                    r.get::<_, Option<String>>(4)?,
399                ))
400            },
401        )
402        .optional()
403        .map_err(|e| e.to_string())?;
404    Ok(match row {
405        Some((t, namespace, generator, actor, source)) => LookupResult {
406            issued: true,
407            namespace,
408            generator,
409            actor,
410            source,
411            created_at: Some(t),
412        },
413        None => LookupResult {
414            issued: false,
415            namespace: None,
416            generator: None,
417            actor: None,
418            source: None,
419            created_at: None,
420        },
421    })
422}
423
424/// Registers an ID that was minted **elsewhere** — the provenance-recording
425/// half, distinct from `mint` which generates in-process.
426pub fn register_external(
427    conn: &Connection,
428    pepper: &[u8; 32],
429    values: &[String],
430    mode: Normalise,
431    meta: &BatchMeta,
432) -> Result<Vec<RegisterOutcome>, String> {
433    register(conn, pepper, values, mode, meta)
434}
435
436// ── Prune ──────────────────────────────────────────────────────────────────────
437
438#[derive(Serialize, Default)]
439pub struct PruneReport {
440    pub matched: u64,
441    pub deleted: u64,
442    pub chunks: u64,
443    pub dry_run: bool,
444}
445
446/// Deletes rows older than `before_ts`, optionally narrowed by namespace,
447/// generator or actor (all resolved through `uid_meta`). Runs in chunks of
448/// `PRUNE_CHUNK` rows in **separate transactions**, so registration and
449/// minting are never blocked behind one long-held writer lock — the measured
450/// reason a single `DELETE` was rejected (45.7s at 10M rows vs. a 0.38s worst
451/// chunk).
452///
453/// This deletes the only evidence an ID was issued: a pruned ID can be issued
454/// again without a collision being detected, and `lookup` answers `unknown`
455/// for an ID that was real. Callers must say so before confirming — see
456/// `unv-cli/src/uid_cmd.rs` and the `/api/uid/prune` handler.
457pub fn prune(
458    conn: &mut Connection,
459    before_ts: i64,
460    namespace: Option<&str>,
461    generator: Option<&str>,
462    actor: Option<&str>,
463    dry_run: bool,
464) -> Result<PruneReport, String> {
465    // One clause used unconditionally, rather than a fast path for "no meta
466    // filter": with every filter `NULL` the subquery matches every batch, so
467    // correctness does not depend on which branch ran — only the query plan
468    // would differ, and this table is small enough that it does not matter.
469    let where_clause = "u.t < ?1 AND u.m IN (SELECT id FROM uid_meta m WHERE \
470         (?2 IS NULL OR m.namespace = ?2) AND \
471         (?3 IS NULL OR m.generator = ?3) AND \
472         (?4 IS NULL OR m.actor = ?4))";
473
474    // rusqlite 0.40 dropped `FromSql for u64` (SQLite integers are signed).
475    let matched: i64 = conn
476        .query_row(
477            &format!("SELECT COUNT(*) FROM uid u WHERE {where_clause}"),
478            params![before_ts, namespace, generator, actor],
479            |r| r.get(0),
480        )
481        .map_err(|e| e.to_string())?;
482    let matched = matched as u64;
483
484    if dry_run {
485        return Ok(PruneReport {
486            matched,
487            deleted: 0,
488            chunks: 0,
489            dry_run: true,
490        });
491    }
492
493    let mut deleted: u64 = 0;
494    let mut chunks: u64 = 0;
495    loop {
496        let tx = conn.transaction().map_err(|e| e.to_string())?;
497        let n = tx
498            .execute(
499                &format!(
500                    "DELETE FROM uid WHERE h IN (SELECT h FROM uid u WHERE {where_clause} LIMIT {PRUNE_CHUNK})"
501                ),
502                params![before_ts, namespace, generator, actor],
503            )
504            .map_err(|e| e.to_string())?;
505        tx.commit().map_err(|e| e.to_string())?;
506        deleted += n as u64;
507        chunks += 1;
508        if n == 0 {
509            break;
510        }
511    }
512    Ok(PruneReport {
513        matched,
514        deleted,
515        chunks,
516        dry_run: false,
517    })
518}
519
520// ── Stats ──────────────────────────────────────────────────────────────────────
521
522#[derive(Serialize)]
523pub struct RegistryStats {
524    pub count: i64,
525    pub size_bytes: i64,
526    pub oldest_ts: Option<i64>,
527}
528
529pub fn stats(conn: &Connection) -> Result<RegistryStats, String> {
530    let count: i64 = conn
531        .query_row("SELECT COUNT(*) FROM uid", [], |r| r.get(0))
532        .map_err(|e| e.to_string())?;
533    let oldest_ts: Option<i64> = conn
534        .query_row("SELECT MIN(t) FROM uid", [], |r| r.get(0))
535        .map_err(|e| e.to_string())?;
536    let page_count: i64 = conn
537        .query_row("PRAGMA page_count", [], |r| r.get(0))
538        .unwrap_or(0);
539    let page_size: i64 = conn
540        .query_row("PRAGMA page_size", [], |r| r.get(0))
541        .unwrap_or(4096);
542    Ok(RegistryStats {
543        count,
544        size_bytes: page_count * page_size,
545        oldest_ts,
546    })
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    fn mem() -> Connection {
554        let conn = Connection::open_in_memory().unwrap();
555        init_schema(&conn).unwrap();
556        conn
557    }
558
559    #[test]
560    fn uuid_normalisation_folds_case_and_dashes() {
561        let a = normalise("550E8400-E29B-41D4-A716-446655440000", Normalise::Uuid);
562        let b = normalise("550e8400e29b41d4a716446655440000", Normalise::Uuid);
563        assert_eq!(a, b);
564        assert_eq!(a, "550e8400-e29b-41d4-a716-446655440000");
565    }
566
567    #[test]
568    fn ulid_normalisation_maps_ambiguous_letters() {
569        // I/L -> 1, O -> 0, per Crockford base32.
570        assert_eq!(normalise("01ILOabc", Normalise::Ulid), "01110ABC");
571    }
572
573    #[test]
574    fn registering_the_same_value_twice_reports_a_conflict_not_two_rows() {
575        let conn = mem();
576        let pepper = [7u8; 32];
577        let meta = BatchMeta::default();
578        let first = register(&conn, &pepper, &["abc".to_string()], Normalise::None, &meta).unwrap();
579        assert!(first[0].registered);
580        let second =
581            register(&conn, &pepper, &["abc".to_string()], Normalise::None, &meta).unwrap();
582        assert!(!second[0].registered);
583        let count: i64 = conn
584            .query_row("SELECT COUNT(*) FROM uid", [], |r| r.get(0))
585            .unwrap();
586        assert_eq!(count, 1);
587    }
588
589    #[test]
590    fn check_is_advisory_and_never_reserves() {
591        let conn = mem();
592        let pepper = [1u8; 32];
593        let r1 = check(&conn, &pepper, &["x".to_string()], Normalise::None).unwrap();
594        assert!(r1[0].1, "unique before anything is registered");
595        let r2 = check(&conn, &pepper, &["x".to_string()], Normalise::None).unwrap();
596        assert!(r2[0].1, "check alone must not have reserved it");
597    }
598
599    #[test]
600    fn lookup_reports_provenance_for_a_registered_value() {
601        let conn = mem();
602        let pepper = [3u8; 32];
603        let meta = BatchMeta {
604            namespace: Some("ci"),
605            generator: Some("uuidv4"),
606            actor: Some("runner-1"),
607            ..Default::default()
608        };
609        register(&conn, &pepper, &["v1".to_string()], Normalise::None, &meta).unwrap();
610        let found = lookup(&conn, &pepper, "v1", Normalise::None).unwrap();
611        assert!(found.issued);
612        assert_eq!(found.namespace.as_deref(), Some("ci"));
613        let missing = lookup(&conn, &pepper, "v2", Normalise::None).unwrap();
614        assert!(!missing.issued);
615    }
616
617    #[test]
618    fn prune_deletes_only_rows_older_than_the_cutoff() {
619        let mut conn = mem();
620        let pepper = [9u8; 32];
621        let meta = BatchMeta::default();
622        register(&conn, &pepper, &["old".to_string()], Normalise::None, &meta).unwrap();
623        conn.execute("UPDATE uid SET t = 100", []).unwrap();
624        register(&conn, &pepper, &["new".to_string()], Normalise::None, &meta).unwrap();
625        conn.execute("UPDATE uid SET t = 999999999 WHERE t != 100", [])
626            .unwrap();
627
628        let dry = prune(&mut conn, 500, None, None, None, true).unwrap();
629        assert_eq!(dry.matched, 1);
630        assert_eq!(dry.deleted, 0);
631
632        let real = prune(&mut conn, 500, None, None, None, false).unwrap();
633        assert_eq!(real.deleted, 1);
634        let remaining: i64 = conn
635            .query_row("SELECT COUNT(*) FROM uid", [], |r| r.get(0))
636            .unwrap();
637        assert_eq!(remaining, 1);
638    }
639
640    #[test]
641    fn a_pruned_value_can_be_registered_again() {
642        // Written down as a consequence, not a bug: pruning deletes the only
643        // evidence an ID was issued.
644        let mut conn = mem();
645        let pepper = [4u8; 32];
646        let meta = BatchMeta::default();
647        register(
648            &conn,
649            &pepper,
650            &["gone".to_string()],
651            Normalise::None,
652            &meta,
653        )
654        .unwrap();
655        conn.execute("UPDATE uid SET t = 1", []).unwrap();
656        prune(&mut conn, 1000, None, None, None, false).unwrap();
657        let again = register(
658            &conn,
659            &pepper,
660            &["gone".to_string()],
661            Normalise::None,
662            &meta,
663        )
664        .unwrap();
665        assert!(again[0].registered);
666    }
667}