core_ipc/
registry.rs

1//! Clearance registry: maps daemon names to verified identities and security levels,
2//! with O(1) pubkey lookups via a reverse index.
3//!
4//! Built by `daemon-profile` at startup from per-daemon keypairs. After Noise IK
5//! handshake, the server extracts the client's X25519 static public key via
6//! `TransportState::get_remote_static()` and resolves it here.
7//!
8//! Keys not in the registry receive `SecurityLevel::SecretsOnly` (ephemeral CLI clients).
9//!
10//! ## Data Model
11//!
12//! `identities: HashMap<String, DaemonIdentity>` is the source of truth, keyed by
13//! daemon name. One entry per daemon. `find_by_name` is O(1) and deterministic.
14//!
15//! `pubkey_index: HashMap<[u8; 32], String>` is a derived reverse index for O(1)
16//! pubkey resolution during Noise handshake. Both `current_pubkey` and `pending_pubkey`
17//! (when present during key rotation) have entries in this index.
18//!
19//! ## Consistency Invariant
20//!
21//! For every `(pubkey, name)` in `pubkey_index`:
22//!   `identities[name].current_pubkey == pubkey || identities[name].pending_pubkey == Some(pubkey)`
23//!
24//! Enforced by all mutation methods. Validated by `debug_assert_consistent()` in debug builds.
25
26use core_types::SecurityLevel;
27use std::collections::HashMap;
28
29/// A daemon's verified identity, clearance, and key state.
30///
31/// Does NOT derive `Serialize` — internal registry state that must never
32/// cross a process boundary or appear in logs, API responses, or error messages.
33#[derive(Debug, Clone)]
34pub struct DaemonIdentity {
35    /// Current active X25519 static public key.
36    pub current_pubkey: [u8; 32],
37    /// Pending rotation pubkey. Set by phase 1, cleared by phase 2 finalization.
38    /// Both current and pending are valid for identity resolution during
39    /// the grace period.
40    pub pending_pubkey: Option<[u8; 32]>,
41    /// Security clearance level for this daemon.
42    pub security_level: SecurityLevel,
43    /// Monotonic generation counter. Incremented on every finalized rotation
44    /// or crash-revocation. Used by two-phase rotation to detect concurrent
45    /// revocations and avoid double-rotation.
46    pub generation: u64,
47}
48
49/// Maps daemon names to identities with O(1) pubkey lookups via reverse index.
50#[derive(Debug, Clone, Default)]
51pub struct ClearanceRegistry {
52    /// Source of truth: daemon name → identity.
53    identities: HashMap<String, DaemonIdentity>,
54    /// Derived cache: pubkey → daemon name. Maintained by mutation methods.
55    pubkey_index: HashMap<[u8; 32], String>,
56}
57
58impl ClearanceRegistry {
59    #[must_use]
60    pub fn new() -> Self {
61        Self {
62            identities: HashMap::new(),
63            pubkey_index: HashMap::new(),
64        }
65    }
66
67    /// Register a daemon with its initial pubkey and clearance level.
68    /// Generation starts at 0. Overwrites any existing registration for this name.
69    pub fn register(&mut self, name: String, pubkey: [u8; 32], level: SecurityLevel) {
70        // Remove any existing pubkeys for this daemon from the reverse index.
71        if let Some(existing) = self.identities.get(&name) {
72            self.pubkey_index.remove(&existing.current_pubkey);
73            if let Some(pending) = existing.pending_pubkey {
74                self.pubkey_index.remove(&pending);
75            }
76        }
77
78        let identity = DaemonIdentity {
79            current_pubkey: pubkey,
80            pending_pubkey: None,
81            security_level: level,
82            generation: 0,
83        };
84        self.identities.insert(name.clone(), identity);
85        self.pubkey_index.insert(pubkey, name);
86        self.debug_assert_consistent();
87    }
88
89    /// Register with an explicit generation (used after revoke-then-reregister).
90    pub fn register_with_generation(
91        &mut self,
92        name: String,
93        pubkey: [u8; 32],
94        level: SecurityLevel,
95        generation: u64,
96    ) {
97        // Remove any existing pubkeys for this daemon from the reverse index.
98        if let Some(existing) = self.identities.get(&name) {
99            self.pubkey_index.remove(&existing.current_pubkey);
100            if let Some(pending) = existing.pending_pubkey {
101                self.pubkey_index.remove(&pending);
102            }
103        }
104
105        let identity = DaemonIdentity {
106            current_pubkey: pubkey,
107            pending_pubkey: None,
108            security_level: level,
109            generation,
110        };
111        self.identities.insert(name.clone(), identity);
112        self.pubkey_index.insert(pubkey, name);
113        self.debug_assert_consistent();
114    }
115
116    /// Look up a daemon identity by pubkey. O(1) via reverse index.
117    /// Returns `None` for unregistered keys (ephemeral CLI clients).
118    #[must_use]
119    pub fn lookup(&self, pubkey: &[u8; 32]) -> Option<&DaemonIdentity> {
120        let name = self.pubkey_index.get(pubkey)?;
121        self.identities.get(name)
122    }
123
124    /// Look up the daemon name for a pubkey. O(1) via reverse index.
125    #[must_use]
126    pub fn lookup_name(&self, pubkey: &[u8; 32]) -> Option<&str> {
127        self.pubkey_index.get(pubkey).map(String::as_str)
128    }
129
130    /// Find a daemon identity by name. O(1). Always deterministic.
131    #[must_use]
132    pub fn find_by_name(&self, name: &str) -> Option<&DaemonIdentity> {
133        self.identities.get(name)
134    }
135
136    /// Register a pending rotation pubkey for an existing daemon.
137    ///
138    /// Both old (current) and new (pending) pubkeys are valid for identity
139    /// resolution during the grace period. The pending key gets the same
140    /// `security_level` and `generation` as the current identity — `register_pending`
141    /// does NOT accept these as parameters to prevent accidental clearance changes.
142    ///
143    /// If a pending key already exists (double rotation without finalize),
144    /// the old pending key is removed from the index and replaced.
145    ///
146    /// Returns `true` if the daemon was found and the pending key was registered.
147    /// Returns `false` if no daemon with that name exists.
148    pub fn register_pending(&mut self, daemon_name: &str, new_pubkey: [u8; 32]) -> bool {
149        let Some(identity) = self.identities.get_mut(daemon_name) else {
150            return false;
151        };
152
153        // Remove any existing pending key from the reverse index.
154        if let Some(old_pending) = identity.pending_pubkey {
155            self.pubkey_index.remove(&old_pending);
156        }
157
158        identity.pending_pubkey = Some(new_pubkey);
159        self.pubkey_index.insert(new_pubkey, daemon_name.to_owned());
160        self.debug_assert_consistent();
161        true
162    }
163
164    /// Finalize rotation: promote pending to current, remove old from index,
165    /// increment generation.
166    ///
167    /// Returns `true` if the daemon was found and had a pending key to finalize.
168    /// Returns `false` if the daemon doesn't exist or has no pending key.
169    pub fn finalize_rotation(&mut self, daemon_name: &str) -> bool {
170        let Some(identity) = self.identities.get_mut(daemon_name) else {
171            return false;
172        };
173
174        let Some(new_pubkey) = identity.pending_pubkey.take() else {
175            return false;
176        };
177
178        // Remove old current pubkey from reverse index.
179        self.pubkey_index.remove(&identity.current_pubkey);
180
181        // Promote pending to current.
182        identity.current_pubkey = new_pubkey;
183        identity.generation += 1;
184        // new_pubkey is already in the reverse index from register_pending().
185
186        self.debug_assert_consistent();
187        true
188    }
189
190    /// Revoke a daemon entirely: remove identity and all pubkey index entries.
191    ///
192    /// Handles the dual-key case atomically: removes both current and pending
193    /// pubkeys from the reverse index in one operation.
194    ///
195    /// Returns the removed identity (for generation continuity) or `None`.
196    pub fn revoke_by_name(&mut self, daemon_name: &str) -> Option<DaemonIdentity> {
197        let identity = self.identities.remove(daemon_name)?;
198
199        // Remove all pubkeys for this daemon from the reverse index.
200        self.pubkey_index.remove(&identity.current_pubkey);
201        if let Some(pending) = identity.pending_pubkey {
202            self.pubkey_index.remove(&pending);
203        }
204
205        self.debug_assert_consistent();
206        Some(identity)
207    }
208
209    /// Snapshot all daemon generations. Used by rotation phase 1 baseline.
210    #[must_use]
211    pub fn snapshot_generations(&self) -> HashMap<String, u64> {
212        self.identities
213            .iter()
214            .map(|(name, id)| (name.clone(), id.generation))
215            .collect()
216    }
217
218    /// Validate internal consistency between primary map and reverse index.
219    ///
220    /// Every pubkey in the reverse index must point to an existing identity
221    /// whose `current_pubkey` or `pending_pubkey` matches. Every pubkey in every
222    /// identity must have a reverse index entry.
223    ///
224    /// Only runs in debug builds. Panics on inconsistency.
225    fn debug_assert_consistent(&self) {
226        #[cfg(debug_assertions)]
227        {
228            // Forward check: every reverse index entry points to a valid identity+pubkey.
229            for (pubkey, name) in &self.pubkey_index {
230                let identity = self
231                    .identities
232                    .get(name)
233                    .unwrap_or_else(|| panic!("pubkey_index points to nonexistent daemon: {name}"));
234                let matches_current = identity.current_pubkey == *pubkey;
235                let matches_pending = identity.pending_pubkey.as_ref() == Some(pubkey);
236                assert!(
237                    matches_current || matches_pending,
238                    "pubkey_index entry for {name} matches neither current nor pending pubkey"
239                );
240            }
241
242            // Reverse check: every pubkey in every identity has a reverse index entry.
243            for (name, identity) in &self.identities {
244                assert_eq!(
245                    self.pubkey_index.get(&identity.current_pubkey),
246                    Some(name),
247                    "current_pubkey for {name} missing from pubkey_index"
248                );
249                if let Some(pending) = &identity.pending_pubkey {
250                    assert_eq!(
251                        self.pubkey_index.get(pending),
252                        Some(name),
253                        "pending_pubkey for {name} missing from pubkey_index"
254                    );
255                }
256            }
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use core_types::SecurityLevel;
265
266    #[test]
267    fn register_and_lookup() {
268        let mut reg = ClearanceRegistry::new();
269        let key = [0xAA; 32];
270        reg.register("daemon-secrets".into(), key, SecurityLevel::SecretsOnly);
271        let identity = reg.lookup(&key).unwrap();
272        assert_eq!(identity.current_pubkey, key);
273        assert_eq!(identity.security_level, SecurityLevel::SecretsOnly);
274        assert_eq!(identity.generation, 0);
275        assert!(identity.pending_pubkey.is_none());
276    }
277
278    #[test]
279    fn lookup_name() {
280        let mut reg = ClearanceRegistry::new();
281        let key = [0xAA; 32];
282        reg.register("daemon-wm".into(), key, SecurityLevel::Internal);
283        assert_eq!(reg.lookup_name(&key), Some("daemon-wm"));
284        assert_eq!(reg.lookup_name(&[0xBB; 32]), None);
285    }
286
287    #[test]
288    fn lookup_miss() {
289        let reg = ClearanceRegistry::new();
290        assert!(reg.lookup(&[0xBB; 32]).is_none());
291    }
292
293    #[test]
294    fn find_by_name_deterministic() {
295        let mut reg = ClearanceRegistry::new();
296        let key = [0xAA; 32];
297        reg.register("daemon-wm".into(), key, SecurityLevel::Internal);
298        let identity = reg.find_by_name("daemon-wm").unwrap();
299        assert_eq!(identity.current_pubkey, key);
300        assert!(reg.find_by_name("nonexistent").is_none());
301    }
302
303    #[test]
304    fn register_overwrites_existing() {
305        let mut reg = ClearanceRegistry::new();
306        let key_a = [0xAA; 32];
307        let key_b = [0xBB; 32];
308        reg.register("daemon-wm".into(), key_a, SecurityLevel::Internal);
309        reg.register("daemon-wm".into(), key_b, SecurityLevel::SecretsOnly);
310
311        // Old key should not resolve.
312        assert!(reg.lookup(&key_a).is_none());
313        // New key should resolve.
314        let identity = reg.lookup(&key_b).unwrap();
315        assert_eq!(identity.security_level, SecurityLevel::SecretsOnly);
316    }
317
318    #[test]
319    fn register_pending_allows_dual_key_lookup() {
320        let mut reg = ClearanceRegistry::new();
321        let old_key = [0xAA; 32];
322        let new_key = [0xBB; 32];
323        reg.register("daemon-wm".into(), old_key, SecurityLevel::Internal);
324
325        assert!(reg.register_pending("daemon-wm", new_key));
326
327        // Both keys resolve to the same daemon.
328        let id_old = reg.lookup(&old_key).unwrap();
329        let id_new = reg.lookup(&new_key).unwrap();
330        assert_eq!(id_old.current_pubkey, old_key);
331        assert_eq!(id_new.current_pubkey, old_key);
332        assert_eq!(id_old.pending_pubkey, Some(new_key));
333        assert_eq!(id_new.pending_pubkey, Some(new_key));
334        assert_eq!(id_old.security_level, SecurityLevel::Internal);
335        assert_eq!(id_new.security_level, SecurityLevel::Internal);
336    }
337
338    #[test]
339    fn register_pending_returns_false_for_unknown_daemon() {
340        let mut reg = ClearanceRegistry::new();
341        assert!(!reg.register_pending("nonexistent", [0xFF; 32]));
342    }
343
344    #[test]
345    fn register_pending_clones_security_level() {
346        let mut reg = ClearanceRegistry::new();
347        reg.register(
348            "daemon-secrets".into(),
349            [0xAA; 32],
350            SecurityLevel::SecretsOnly,
351        );
352        reg.register_pending("daemon-secrets", [0xBB; 32]);
353
354        // Lookup via new key should show SecretsOnly (cloned from existing).
355        let identity = reg.lookup(&[0xBB; 32]).unwrap();
356        assert_eq!(identity.security_level, SecurityLevel::SecretsOnly);
357    }
358
359    #[test]
360    fn register_pending_replaces_existing_pending() {
361        let mut reg = ClearanceRegistry::new();
362        reg.register("daemon-wm".into(), [0xAA; 32], SecurityLevel::Internal);
363        reg.register_pending("daemon-wm", [0xBB; 32]);
364        reg.register_pending("daemon-wm", [0xCC; 32]);
365
366        // Old pending key should not resolve.
367        assert!(reg.lookup(&[0xBB; 32]).is_none());
368        // New pending key should resolve.
369        assert!(reg.lookup(&[0xCC; 32]).is_some());
370        // Current key still resolves.
371        assert!(reg.lookup(&[0xAA; 32]).is_some());
372    }
373
374    #[test]
375    fn finalize_rotation_promotes_pending() {
376        let mut reg = ClearanceRegistry::new();
377        let old_key = [0xAA; 32];
378        let new_key = [0xBB; 32];
379        reg.register("daemon-wm".into(), old_key, SecurityLevel::Internal);
380        reg.register_pending("daemon-wm", new_key);
381
382        assert!(reg.finalize_rotation("daemon-wm"));
383
384        let identity = reg.find_by_name("daemon-wm").unwrap();
385        assert_eq!(identity.current_pubkey, new_key);
386        assert!(identity.pending_pubkey.is_none());
387    }
388
389    #[test]
390    fn finalize_rotation_removes_old_from_index() {
391        let mut reg = ClearanceRegistry::new();
392        let old_key = [0xAA; 32];
393        let new_key = [0xBB; 32];
394        reg.register("daemon-wm".into(), old_key, SecurityLevel::Internal);
395        reg.register_pending("daemon-wm", new_key);
396
397        reg.finalize_rotation("daemon-wm");
398
399        // Old key no longer resolves.
400        assert!(reg.lookup(&old_key).is_none());
401        // New key still resolves.
402        assert!(reg.lookup(&new_key).is_some());
403    }
404
405    #[test]
406    fn finalize_rotation_increments_generation() {
407        let mut reg = ClearanceRegistry::new();
408        reg.register("daemon-wm".into(), [0xAA; 32], SecurityLevel::Internal);
409        reg.register_pending("daemon-wm", [0xBB; 32]);
410        reg.finalize_rotation("daemon-wm");
411
412        assert_eq!(reg.find_by_name("daemon-wm").unwrap().generation, 1);
413    }
414
415    #[test]
416    fn finalize_rotation_returns_false_without_pending() {
417        let mut reg = ClearanceRegistry::new();
418        reg.register("daemon-wm".into(), [0xAA; 32], SecurityLevel::Internal);
419
420        // No pending key registered.
421        assert!(!reg.finalize_rotation("daemon-wm"));
422        // Identity unchanged.
423        assert_eq!(reg.find_by_name("daemon-wm").unwrap().generation, 0);
424    }
425
426    #[test]
427    fn finalize_rotation_returns_false_for_unknown() {
428        let mut reg = ClearanceRegistry::new();
429        assert!(!reg.finalize_rotation("nonexistent"));
430    }
431
432    #[test]
433    fn revoke_by_name_removes_both_keys() {
434        let mut reg = ClearanceRegistry::new();
435        let old_key = [0xAA; 32];
436        let new_key = [0xBB; 32];
437        reg.register("daemon-wm".into(), old_key, SecurityLevel::Internal);
438        reg.register_pending("daemon-wm", new_key);
439
440        let revoked = reg.revoke_by_name("daemon-wm").unwrap();
441        assert_eq!(revoked.generation, 0);
442
443        // Both keys removed from reverse index.
444        assert!(reg.lookup(&old_key).is_none());
445        assert!(reg.lookup(&new_key).is_none());
446        // Name removed from primary map.
447        assert!(reg.find_by_name("daemon-wm").is_none());
448    }
449
450    #[test]
451    fn revoke_by_name_returns_none_for_unknown() {
452        let mut reg = ClearanceRegistry::new();
453        assert!(reg.revoke_by_name("nonexistent").is_none());
454    }
455
456    #[test]
457    fn revoke_by_name_returns_identity_with_generation() {
458        let mut reg = ClearanceRegistry::new();
459        reg.register("daemon-wm".into(), [0xAA; 32], SecurityLevel::Internal);
460        reg.register_pending("daemon-wm", [0xBB; 32]);
461        reg.finalize_rotation("daemon-wm"); // gen = 1
462        reg.register_pending("daemon-wm", [0xCC; 32]);
463        // gen still 1 (pending not finalized yet)
464
465        let revoked = reg.revoke_by_name("daemon-wm").unwrap();
466        assert_eq!(revoked.generation, 1);
467    }
468
469    #[test]
470    fn register_with_generation_preserves_counter() {
471        let mut reg = ClearanceRegistry::new();
472        let key = [0xDD; 32];
473        reg.register_with_generation("daemon-wm".into(), key, SecurityLevel::Internal, 5);
474        assert_eq!(reg.lookup(&key).unwrap().generation, 5);
475    }
476
477    #[test]
478    fn snapshot_generations_captures_all_daemons() {
479        let mut reg = ClearanceRegistry::new();
480        reg.register("daemon-wm".into(), [0xAA; 32], SecurityLevel::Internal);
481        reg.register(
482            "daemon-secrets".into(),
483            [0xBB; 32],
484            SecurityLevel::SecretsOnly,
485        );
486
487        // Finalize one rotation for daemon-wm.
488        reg.register_pending("daemon-wm", [0xCC; 32]);
489        reg.finalize_rotation("daemon-wm");
490
491        let snap = reg.snapshot_generations();
492        assert_eq!(snap["daemon-wm"], 1);
493        assert_eq!(snap["daemon-secrets"], 0);
494    }
495
496    // SECURITY INVARIANT: double finalization must be safe (returns false, no state change).
497    #[test]
498    fn double_finalize_is_safe() {
499        let mut reg = ClearanceRegistry::new();
500        reg.register("daemon-wm".into(), [0xAA; 32], SecurityLevel::Internal);
501        reg.register_pending("daemon-wm", [0xBB; 32]);
502
503        assert!(reg.finalize_rotation("daemon-wm"));
504        assert!(!reg.finalize_rotation("daemon-wm"));
505        assert_eq!(reg.find_by_name("daemon-wm").unwrap().generation, 1);
506    }
507
508    // SECURITY INVARIANT: two full rotation cycles must produce generation 2.
509    #[test]
510    fn double_rotation_increments_generation_twice() {
511        let mut reg = ClearanceRegistry::new();
512        reg.register("daemon-wm".into(), [0xAA; 32], SecurityLevel::Internal);
513
514        reg.register_pending("daemon-wm", [0xBB; 32]);
515        reg.finalize_rotation("daemon-wm"); // gen = 1
516
517        reg.register_pending("daemon-wm", [0xCC; 32]);
518        reg.finalize_rotation("daemon-wm"); // gen = 2
519
520        let identity = reg.find_by_name("daemon-wm").unwrap();
521        assert_eq!(identity.generation, 2);
522        assert_eq!(identity.current_pubkey, [0xCC; 32]);
523        assert!(reg.lookup(&[0xAA; 32]).is_none());
524        assert!(reg.lookup(&[0xBB; 32]).is_none());
525        assert!(reg.lookup(&[0xCC; 32]).is_some());
526    }
527
528    // SECURITY INVARIANT: crash-restart path must preserve generation continuity.
529    // revoke_by_name returns the identity with current generation; re-register with gen+1.
530    #[test]
531    fn revoke_then_reregister_preserves_generation_continuity() {
532        let mut reg = ClearanceRegistry::new();
533        reg.register("daemon-wm".into(), [0xAA; 32], SecurityLevel::Internal);
534        reg.register_pending("daemon-wm", [0xBB; 32]);
535        reg.finalize_rotation("daemon-wm"); // gen = 1
536
537        let revoked = reg.revoke_by_name("daemon-wm").unwrap();
538        assert_eq!(revoked.generation, 1);
539
540        reg.register_with_generation(
541            "daemon-wm".into(),
542            [0xCC; 32],
543            SecurityLevel::Internal,
544            revoked.generation + 1,
545        );
546        assert_eq!(reg.find_by_name("daemon-wm").unwrap().generation, 2);
547    }
548
549    // SECURITY INVARIANT: revoke during pending must clean both keys.
550    #[test]
551    fn revoke_during_pending_cleans_both() {
552        let mut reg = ClearanceRegistry::new();
553        reg.register("daemon-wm".into(), [0xAA; 32], SecurityLevel::Internal);
554        reg.register_pending("daemon-wm", [0xBB; 32]);
555
556        reg.revoke_by_name("daemon-wm");
557
558        assert!(reg.lookup(&[0xAA; 32]).is_none());
559        assert!(reg.lookup(&[0xBB; 32]).is_none());
560        assert!(reg.find_by_name("daemon-wm").is_none());
561    }
562
563    // SECURITY INVARIANT: any key NOT in the registry must return None on lookup.
564    // The server assigns SecretsOnly clearance to None — elevated clearance must
565    // never be granted to unregistered keys.
566    #[test]
567    fn unregistered_keys_always_return_none() {
568        let mut reg = ClearanceRegistry::new();
569        reg.register("daemon-wm".into(), [0xAA; 32], SecurityLevel::Internal);
570        reg.register(
571            "daemon-secrets".into(),
572            [0xBB; 32],
573            SecurityLevel::SecretsOnly,
574        );
575
576        for byte in [
577            0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xCC, 0xDD, 0xEE, 0xFF,
578        ] {
579            assert!(
580                reg.lookup(&[byte; 32]).is_none(),
581                "key [{byte:#04X}; 32] should not be in registry"
582            );
583        }
584    }
585
586    // SECURITY INVARIANT: find_by_name must return the correct identity after rotation.
587    #[test]
588    fn find_by_name_tracks_through_rotation() {
589        let mut reg = ClearanceRegistry::new();
590        reg.register("daemon-wm".into(), [0xAA; 32], SecurityLevel::Internal);
591        reg.register_pending("daemon-wm", [0xBB; 32]);
592        reg.finalize_rotation("daemon-wm");
593
594        let identity = reg.find_by_name("daemon-wm").unwrap();
595        assert_eq!(identity.current_pubkey, [0xBB; 32]);
596        assert_eq!(identity.generation, 1);
597    }
598}