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