Skip to main content

klieo_ops/
key_registry.rs

1//! Shared Ed25519 key-lookup registry generic over the ID type.
2//!
3//! Used by both `ApproverRegistry` (keyed by `ApproverId`) and
4//! `SourceIdentityRegistry` (keyed by `AgentId`). The trait surface is
5//! intentionally separate per call-site to keep cross-domain keyspaces
6//! distinct in the type system.
7
8use ed25519_dalek::VerifyingKey;
9use std::collections::HashMap;
10use std::path::Path;
11use thiserror::Error;
12
13/// Errors raised by registry loaders.
14#[derive(Debug, Error)]
15#[non_exhaustive]
16pub enum KeyRegistryError {
17    /// YAML load / parse failure.
18    #[error("key registry load: {0}")]
19    Load(String),
20    /// Pubkey not a valid 32-byte Ed25519 verifying key.
21    #[error("key registry: pubkey for `{id}` is not a valid 32-byte Ed25519 key")]
22    BadKey {
23        /// Offending id (display string).
24        id: String,
25    },
26}
27
28/// Common Ed25519 key registry, generic over the ID newtype.
29pub trait Ed25519KeyRegistry<Id>: Send + Sync
30where
31    Id: Eq + std::hash::Hash + std::fmt::Display,
32{
33    /// Resolve `id` to a trusted Ed25519 verifying key, or `None` if
34    /// the id is not registered.
35    fn lookup(&self, id: &Id) -> Option<VerifyingKey>;
36}
37
38/// In-memory `HashMap`-backed registry.
39pub struct StaticEd25519KeyRegistry<Id>
40where
41    Id: Eq + std::hash::Hash + std::fmt::Display,
42{
43    keys: HashMap<Id, VerifyingKey>,
44}
45
46impl<Id> StaticEd25519KeyRegistry<Id>
47where
48    Id: Eq + std::hash::Hash + std::fmt::Display,
49{
50    /// Build from an in-memory map.
51    #[must_use]
52    pub fn from_map(keys: HashMap<Id, VerifyingKey>) -> Self {
53        Self { keys }
54    }
55}
56
57impl<Id> Ed25519KeyRegistry<Id> for StaticEd25519KeyRegistry<Id>
58where
59    Id: Eq + std::hash::Hash + std::fmt::Display + Send + Sync,
60{
61    fn lookup(&self, id: &Id) -> Option<VerifyingKey> {
62        self.keys.get(id).copied()
63    }
64}
65
66/// Load a YAML map of id → base64-encoded Ed25519 verifying key.
67///
68/// The `make_id` closure constructs the concrete ID newtype from the
69/// raw string key. Used by `StaticApproverRegistry::from_yaml` and
70/// `StaticSourceIdentityRegistry::from_yaml`.
71pub fn load_ed25519_yaml<Id>(
72    path: impl AsRef<Path>,
73    make_id: impl Fn(String) -> Id,
74) -> Result<HashMap<Id, VerifyingKey>, KeyRegistryError>
75where
76    Id: Eq + std::hash::Hash + std::fmt::Display,
77{
78    let body = std::fs::read_to_string(path.as_ref())
79        .map_err(|e| KeyRegistryError::Load(format!("read: {e}")))?;
80    let raw: HashMap<String, String> =
81        serde_yaml::from_str(&body).map_err(|e| KeyRegistryError::Load(format!("parse: {e}")))?;
82    let mut keys = HashMap::with_capacity(raw.len());
83    for (id_raw, b64) in raw {
84        use base64::Engine as _;
85        let bytes = base64::engine::general_purpose::STANDARD
86            .decode(b64)
87            .map_err(|e| KeyRegistryError::Load(format!("base64 `{id_raw}`: {e}")))?;
88        let id = make_id(id_raw);
89        let pk_bytes: [u8; 32] = bytes
90            .try_into()
91            .map_err(|_| KeyRegistryError::BadKey { id: id.to_string() })?;
92        let vk = VerifyingKey::from_bytes(&pk_bytes)
93            .map_err(|_| KeyRegistryError::BadKey { id: id.to_string() })?;
94        keys.insert(id, vk);
95    }
96    Ok(keys)
97}
98
99#[cfg(feature = "hot-reload")]
100pub use hot_reload::HotReloadableEd25519Registry;
101
102#[cfg(feature = "hot-reload")]
103mod hot_reload {
104    //! YAML-watching hot-reload wrapper around `Ed25519KeyRegistry<Id>`.
105    //!
106    //! Wraps an `Arc<HashMap>` behind `arc-swap` so reads are wait-free.
107    //! On file modification the `notify-debouncer-mini` debouncer aggregates
108    //! rapid filesystem events into a single callback (500ms window), then
109    //! re-parses and atomically swaps the in-memory map. Parse errors keep
110    //! the previous snapshot and emit a `tracing::warn!` — the registry never
111    //! panics or serves a partially-loaded state.
112    //!
113    //! The debounce logic runs on notify-debouncer-mini's own thread rather
114    //! than the notify dispatcher thread, which means file saves no longer
115    //! serialise subsequent watcher events behind a `thread::sleep`.
116
117    use super::{load_ed25519_yaml, Ed25519KeyRegistry, KeyRegistryError};
118    use arc_swap::ArcSwap;
119    use ed25519_dalek::VerifyingKey;
120    use notify_debouncer_mini::{new_debouncer, notify, DebounceEventResult};
121    use std::collections::HashMap;
122    use std::path::Path;
123    use std::sync::Arc;
124    use std::time::Duration;
125
126    /// Hot-reloading wrapper around an in-memory Ed25519 key registry.
127    ///
128    /// Construct with [`HotReloadableEd25519Registry::watch_yaml`].
129    /// Uses `notify-debouncer-mini` so the 500ms debounce window runs off the
130    /// notify dispatcher thread; high-frequency file events do not stall
131    /// subsequent watcher callbacks. Reads via [`Ed25519KeyRegistry::lookup`]
132    /// are wait-free and never block.
133    #[non_exhaustive]
134    pub struct HotReloadableEd25519Registry<Id>
135    where
136        Id: Eq + std::hash::Hash + std::fmt::Display + Send + Sync + 'static,
137    {
138        keys: Arc<ArcSwap<HashMap<Id, VerifyingKey>>>,
139        // Keep the debouncer alive for the full lifetime of the registry.
140        _debouncer: notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>,
141    }
142
143    impl<Id> HotReloadableEd25519Registry<Id>
144    where
145        Id: Eq + std::hash::Hash + std::fmt::Display + Send + Sync + Clone + 'static,
146    {
147        /// Build from a YAML file path.
148        ///
149        /// Performs an initial load (fails fast on parse error) then starts
150        /// watching the file for changes. `make_id` constructs the concrete
151        /// ID newtype from the raw YAML key string.
152        pub fn watch_yaml(
153            path: impl AsRef<Path>,
154            make_id: impl Fn(String) -> Id + Send + Sync + 'static,
155        ) -> Result<Self, KeyRegistryError> {
156            let path = path.as_ref().to_path_buf();
157            let initial = load_ed25519_yaml(&path, &make_id)?;
158            let keys = Arc::new(ArcSwap::from_pointee(initial));
159
160            let keys_for_watch = keys.clone();
161            let path_for_watch = path.clone();
162            let make_id_arc: Arc<dyn Fn(String) -> Id + Send + Sync> = Arc::new(make_id);
163
164            let mut debouncer = new_debouncer(
165                Duration::from_millis(500),
166                move |result: DebounceEventResult| {
167                    match result {
168                        Err(err) => {
169                            tracing::warn!(
170                                target: "klieo.ops.key_registry.hot_reload",
171                                error = %err,
172                                "watcher error; keeping previous snapshot"
173                            );
174                            return;
175                        }
176                        Ok(events) => {
177                            // Only act on modify/create events; ignore access/remove.
178                            let relevant = events.iter().any(|e| {
179                                matches!(e.kind, notify_debouncer_mini::DebouncedEventKind::Any)
180                            });
181                            if !relevant {
182                                return;
183                            }
184                        }
185                    }
186                    match load_ed25519_yaml(&path_for_watch, |s| (make_id_arc)(s)) {
187                        Ok(new_keys) => {
188                            keys_for_watch.store(Arc::new(new_keys));
189                            tracing::info!(
190                                target: "klieo.ops.key_registry.hot_reload",
191                                path = %path_for_watch.display(),
192                                "key registry reloaded"
193                            );
194                        }
195                        Err(err) => {
196                            tracing::warn!(
197                                target: "klieo.ops.key_registry.hot_reload",
198                                path = %path_for_watch.display(),
199                                error = %err,
200                                "reload failed; keeping previous snapshot"
201                            );
202                        }
203                    }
204                },
205            )
206            .map_err(|e| KeyRegistryError::Load(format!("debouncer init: {e}")))?;
207
208            debouncer
209                .watcher()
210                .watch(&path, notify::RecursiveMode::NonRecursive)
211                .map_err(|e| KeyRegistryError::Load(format!("watch start: {e}")))?;
212
213            Ok(Self {
214                keys,
215                _debouncer: debouncer,
216            })
217        }
218    }
219
220    impl<Id> Ed25519KeyRegistry<Id> for HotReloadableEd25519Registry<Id>
221    where
222        Id: Eq + std::hash::Hash + std::fmt::Display + Send + Sync + 'static,
223    {
224        fn lookup(&self, id: &Id) -> Option<VerifyingKey> {
225            self.keys.load().get(id).copied()
226        }
227    }
228}