Skip to main content

kaish_kernel/
nonce.rs

1//! Confirmation nonce store for dangerous operations.
2//!
3//! Used by the latch system (`set -o latch`) to gate destructive commands
4//! behind a nonce-based confirmation flow. Nonces are time-limited and
5//! reusable within their TTL for idempotent retries.
6//!
7//! Nonces are path-scoped: a nonce issued for `rm fileA` cannot confirm
8//! `rm fileB`. Validation checks both the command and that confirmed paths
9//! are a subset of the authorized paths.
10
11use std::collections::{BTreeSet, HashMap};
12use std::hash::{BuildHasher, Hasher};
13use std::sync::{Arc, Mutex};
14use std::time::{Duration, SystemTime};
15
16use kaish_types::clock::{system_now, Instant};
17
18/// What a nonce authorizes: a command and a set of paths.
19#[derive(Debug, Clone)]
20pub struct NonceScope {
21    /// Command name (e.g. "rm", "kaish-trash empty").
22    command: String,
23    /// Authorized paths. Empty means no path constraint (command-only ops).
24    paths: BTreeSet<String>,
25}
26
27impl NonceScope {
28    /// The command this nonce authorizes (e.g. "rm").
29    pub fn command(&self) -> &str {
30        &self.command
31    }
32
33    /// The paths this nonce authorizes. Empty means command-only (no path constraint).
34    pub fn paths(&self) -> &BTreeSet<String> {
35        &self.paths
36    }
37}
38
39/// A store for confirmation nonces with TTL-based expiration.
40///
41/// Nonces are 8-character hex strings that gate dangerous operations.
42/// They remain valid until their TTL expires — not consumed on validation —
43/// making operations idempotent: a retried `rm --confirm=abc123 bigdir/`
44/// works if the nonce hasn't expired.
45#[derive(Clone, Debug)]
46pub struct NonceStore {
47    inner: Arc<Mutex<NonceStoreInner>>,
48    ttl: Duration,
49}
50
51#[derive(Debug)]
52struct NonceStoreInner {
53    /// Map from nonce string to (created_at, scope).
54    nonces: HashMap<String, (Instant, NonceScope)>,
55}
56
57impl NonceStore {
58    /// Create a new nonce store with the default TTL (60 seconds).
59    pub fn new() -> Self {
60        Self::with_ttl(Duration::from_secs(60))
61    }
62
63    /// Create a new nonce store with a custom TTL.
64    pub fn with_ttl(ttl: Duration) -> Self {
65        Self {
66            inner: Arc::new(Mutex::new(NonceStoreInner {
67                nonces: HashMap::new(),
68            })),
69            ttl,
70        }
71    }
72
73    /// Look up a nonce's scope without validating against a command/path.
74    ///
75    /// Returns the scope if the nonce exists and hasn't expired, or an error.
76    /// Useful for embedders building custom confirmation UIs.
77    pub fn lookup(&self, nonce: &str) -> Result<NonceScope, String> {
78        let now = Instant::now();
79        let ttl = self.ttl;
80
81        #[allow(clippy::expect_used)]
82        let inner = self.inner.lock().expect("nonce store poisoned");
83
84        match inner.nonces.get(nonce) {
85            Some((created, scope)) => {
86                if now.duration_since(*created) >= ttl {
87                    Err("nonce expired".to_string())
88                } else {
89                    Ok(scope.clone())
90                }
91            }
92            None => Err("invalid nonce".to_string()),
93        }
94    }
95
96    /// Issue a new nonce for the given command and paths.
97    ///
98    /// Returns an 8-character hex string. Opportunistically GCs expired nonces.
99    pub fn issue(&self, command: &str, paths: &[&str]) -> String {
100        let nonce = generate_nonce();
101        let now = Instant::now();
102        let ttl = self.ttl;
103
104        let scope = NonceScope {
105            command: command.to_string(),
106            paths: paths.iter().map(|p| p.to_string()).collect(),
107        };
108
109        #[allow(clippy::expect_used)]
110        let mut inner = self.inner.lock().expect("nonce store poisoned");
111
112        // Opportunistic GC: remove expired nonces
113        inner.nonces.retain(|_, (created, _)| now.duration_since(*created) < ttl);
114
115        inner.nonces.insert(nonce.clone(), (now, scope));
116        nonce
117    }
118
119    /// Validate a nonce against a command and paths.
120    ///
121    /// Checks that the nonce exists, hasn't expired, the command matches,
122    /// and the confirmed paths are a subset of the authorized paths.
123    ///
124    /// Does NOT consume the nonce — it stays valid until TTL expires.
125    pub fn validate(&self, nonce: &str, command: &str, paths: &[&str]) -> Result<(), String> {
126        let now = Instant::now();
127        let ttl = self.ttl;
128
129        #[allow(clippy::expect_used)]
130        let inner = self.inner.lock().expect("nonce store poisoned");
131
132        match inner.nonces.get(nonce) {
133            Some((created, scope)) => {
134                if now.duration_since(*created) >= ttl {
135                    return Err("nonce expired".to_string());
136                }
137
138                if scope.command != command {
139                    return Err(format!(
140                        "nonce scope mismatch: issued for command '{}', got '{}'",
141                        scope.command, command
142                    ));
143                }
144
145                // Every confirmed path must be in the authorized set.
146                // Short-circuit on first unauthorized path — slices are typically 0-1 elements.
147                if let Some(unauthorized) = paths.iter().find(|p| !scope.paths.contains(**p)) {
148                    return Err(format!(
149                        "nonce scope mismatch: unauthorized path '{}' (authorized: {:?})",
150                        unauthorized,
151                        scope.paths.iter().collect::<Vec<_>>()
152                    ));
153                }
154
155                Ok(())
156            }
157            None => Err("invalid nonce".to_string()),
158        }
159    }
160
161    /// Get the TTL for nonces in this store.
162    pub fn ttl(&self) -> Duration {
163        self.ttl
164    }
165}
166
167impl Default for NonceStore {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173/// Generate an 8-character hex nonce using RandomState + SystemTime.
174fn generate_nonce() -> String {
175    let hasher_state = std::collections::hash_map::RandomState::new();
176    let mut hasher = hasher_state.build_hasher();
177
178    // Mix in current time for uniqueness
179    let now = system_now()
180        .duration_since(SystemTime::UNIX_EPOCH)
181        .unwrap_or_default();
182    hasher.write_u128(now.as_nanos());
183
184    // Mix in a second RandomState for additional entropy
185    let hasher_state2 = std::collections::hash_map::RandomState::new();
186    let mut hasher2 = hasher_state2.build_hasher();
187    hasher2.write_u64(0xdeadbeef);
188    hasher.write_u64(hasher2.finish());
189
190    format!("{:08x}", hasher.finish() as u32)
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn issue_and_validate() {
199        let store = NonceStore::new();
200        let nonce = store.issue("rm", &["/tmp/important"]);
201        assert_eq!(nonce.len(), 8);
202        assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()));
203
204        let result = store.validate(&nonce, "rm", &["/tmp/important"]);
205        assert!(result.is_ok());
206    }
207
208    #[test]
209    fn idempotent_reuse() {
210        let store = NonceStore::new();
211        let nonce = store.issue("rm", &["bigdir/"]);
212
213        let first = store.validate(&nonce, "rm", &["bigdir/"]);
214        let second = store.validate(&nonce, "rm", &["bigdir/"]);
215        assert!(first.is_ok());
216        assert!(second.is_ok());
217    }
218
219    #[test]
220    fn expired_nonce_fails() {
221        let store = NonceStore::with_ttl(Duration::from_millis(0));
222        let nonce = store.issue("rm", &["ephemeral"]);
223
224        // With 0ms TTL, nonce is immediately expired
225        std::thread::sleep(Duration::from_millis(1));
226        let result = store.validate(&nonce, "rm", &["ephemeral"]);
227        assert_eq!(result, Err("nonce expired".to_string()));
228    }
229
230    #[test]
231    fn invalid_nonce_fails() {
232        let store = NonceStore::new();
233        let result = store.validate("bogus123", "rm", &["anything"]);
234        assert_eq!(result, Err("invalid nonce".to_string()));
235    }
236
237    #[test]
238    fn nonces_are_unique() {
239        let store = NonceStore::new();
240        let a = store.issue("rm", &["first"]);
241        let b = store.issue("rm", &["second"]);
242        assert_ne!(a, b);
243    }
244
245    #[test]
246    fn clone_shares_state() {
247        let store = NonceStore::new();
248        let cloned = store.clone();
249        let nonce = store.issue("rm", &["/shared"]);
250
251        let result = cloned.validate(&nonce, "rm", &["/shared"]);
252        assert!(result.is_ok());
253    }
254
255    #[test]
256    fn gc_cleans_expired() {
257        let store = NonceStore::with_ttl(Duration::from_millis(10));
258        let old_nonce = store.issue("rm", &["old"]);
259
260        std::thread::sleep(Duration::from_millis(20));
261
262        // This issue() triggers GC
263        let _new = store.issue("rm", &["new"]);
264
265        // Old nonce should be gone (GC'd)
266        let result = store.validate(&old_nonce, "rm", &["old"]);
267        assert!(result.is_err());
268    }
269
270    // ── Path-scoping tests ──
271
272    #[test]
273    fn path_mismatch_rejected() {
274        let store = NonceStore::new();
275        let nonce = store.issue("rm", &["fileA.txt"]);
276
277        let result = store.validate(&nonce, "rm", &["fileB.txt"]);
278        assert!(result.is_err());
279        assert!(result.unwrap_err().contains("nonce scope mismatch"));
280    }
281
282    #[test]
283    fn subset_accepted() {
284        let store = NonceStore::new();
285        let nonce = store.issue("rm", &["a.txt", "b.txt", "c.txt"]);
286
287        // Subset of authorized paths — should succeed
288        let result = store.validate(&nonce, "rm", &["a.txt", "b.txt"]);
289        assert!(result.is_ok());
290    }
291
292    #[test]
293    fn superset_rejected() {
294        let store = NonceStore::new();
295        let nonce = store.issue("rm", &["a.txt", "b.txt"]);
296
297        // Superset — c.txt not authorized
298        let result = store.validate(&nonce, "rm", &["a.txt", "b.txt", "c.txt"]);
299        assert!(result.is_err());
300        assert!(result.unwrap_err().contains("unauthorized"));
301    }
302
303    #[test]
304    fn command_mismatch_rejected() {
305        let store = NonceStore::new();
306        let nonce = store.issue("rm", &["file.txt"]);
307
308        let result = store.validate(&nonce, "kaish-trash empty", &[]);
309        assert!(result.is_err());
310        assert!(result.unwrap_err().contains("command"));
311    }
312
313    #[test]
314    fn empty_paths_command_only() {
315        let store = NonceStore::new();
316        let nonce = store.issue("kaish-trash empty", &[]);
317
318        let result = store.validate(&nonce, "kaish-trash empty", &[]);
319        assert!(result.is_ok());
320    }
321
322    #[test]
323    fn empty_paths_rejects_nonempty() {
324        let store = NonceStore::new();
325        let nonce = store.issue("kaish-trash empty", &[]);
326
327        // Nonce was issued with no paths — can't use it to authorize a path
328        let result = store.validate(&nonce, "kaish-trash empty", &["sneaky.txt"]);
329        assert!(result.is_err());
330    }
331
332}