Skip to main content

chio_kernel/
execution_nonce.rs

1//! Execution nonces prevent TOCTOU races between capability evaluation and tool-server dispatch.
2//!
3//! An `ExecutionNonce` is a short-lived, single-use token that the kernel
4//! attaches to every `Verdict::Allow` response. Tool servers MUST present
5//! the nonce before executing; the kernel rejects stale (>`nonce_ttl_secs`,
6//! default 30s) or replayed nonces. This closes the time-of-check /
7//! time-of-use window between `evaluate()` and tool-server execution that
8//! DPoP alone cannot close.
9//!
10//! # Design
11//!
12//! * The nonce body is an opaque `nonce_id` plus a `NonceBinding` that
13//!   binds the nonce to the exact `(subject, capability, server, tool,
14//!   request_id, parameter_hash)` tuple. Substituting a nonce between unrelated tool
15//!   calls therefore fails the binding check.
16//! * The kernel signs the full body (nonce id + binding + expires_at)
17//!   with its receipt-signing key, so downstream tool servers can
18//!   cryptographically verify authenticity without a round trip.
19//! * Replay is prevented by an `ExecutionNonceStore`: the first
20//!   `reserve(nonce_id)` returns true and consumes the nonce; any
21//!   subsequent reservation returns false and the verify path rejects.
22//!
23//! # Backward compatibility
24//!
25//! The whole feature is opt-in by installing an `ExecutionNonceConfig`.
26//! With no config installed, no nonce is minted and non-nonce callers keep
27//! working. With a config installed and `require_nonce == false`, allow
28//! responses carry nonces and dispatch verifies any nonce that is presented,
29//! but callers that omit the nonce remain backward-compatible. New strict
30//! deployments flip `require_nonce` to make every execution-bound dispatch
31//! present a fresh nonce.
32
33use std::num::NonZeroUsize;
34use std::sync::Mutex;
35use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
36
37use chio_core::canonical::canonical_json_bytes;
38use chio_core::crypto::{Keypair, PublicKey};
39use lru::LruCache;
40use tracing::{error, warn};
41use uuid::Uuid;
42
43use crate::KernelError;
44
45pub use chio_core_types::message::{ExecutionNonce, NonceBinding, SignedExecutionNonce};
46
47/// Schema identifier for Chio execution nonces.
48pub const EXECUTION_NONCE_SCHEMA: &str = "chio.execution_nonce.v1";
49
50/// Default TTL for a freshly minted execution nonce.
51pub const DEFAULT_EXECUTION_NONCE_TTL_SECS: u64 = 30;
52
53/// Default capacity for the in-memory replay-prevention LRU cache.
54pub const DEFAULT_EXECUTION_NONCE_STORE_CAPACITY: usize = 16_384;
55
56#[must_use]
57pub fn is_supported_execution_nonce_schema(schema: &str) -> bool {
58    schema == EXECUTION_NONCE_SCHEMA
59}
60
61// ---------------------------------------------------------------------------
62// ExecutionNonceConfig
63// ---------------------------------------------------------------------------
64
65/// Configuration for execution nonce issuance and verification.
66#[derive(Debug, Clone)]
67pub struct ExecutionNonceConfig {
68    /// How many seconds a nonce is valid after issuance. Default: 30.
69    pub nonce_ttl_secs: u64,
70    /// Maximum entries in the replay-prevention LRU cache. Default: 16_384.
71    pub nonce_store_capacity: usize,
72    /// When `true`, the kernel's strict-mode verify paths reject any call
73    /// that does not present a signed nonce. Default: `false` (opt-in).
74    pub require_nonce: bool,
75}
76
77impl Default for ExecutionNonceConfig {
78    fn default() -> Self {
79        Self {
80            nonce_ttl_secs: DEFAULT_EXECUTION_NONCE_TTL_SECS,
81            nonce_store_capacity: DEFAULT_EXECUTION_NONCE_STORE_CAPACITY,
82            require_nonce: false,
83        }
84    }
85}
86
87// ---------------------------------------------------------------------------
88// ExecutionNonceStore trait
89// ---------------------------------------------------------------------------
90
91/// Persistence boundary for replay-prevention of execution nonces.
92///
93/// Implementations MUST ensure that `reserve(nonce_id)` returns `true`
94/// exactly once per nonce identifier. All subsequent calls for the same
95/// identifier return `false`. Fail-closed: any internal error is returned
96/// via `KernelError` so the caller can deny the request.
97pub trait ExecutionNonceStore: Send + Sync {
98    /// Attempt to reserve (consume) the given nonce identifier.
99    ///
100    /// * `Ok(true)`  -- nonce was fresh; it is now marked consumed.
101    /// * `Ok(false)` -- nonce has already been consumed (replay detected).
102    /// * `Err(_)`    -- the store is unreachable or corrupted; fail-closed.
103    ///
104    /// Prefer [`Self::reserve_until`] when the caller knows the signed
105    /// expiry of the nonce: durable stores need to retain the consumed
106    /// marker at least as long as the signed nonce is valid, otherwise
107    /// the row may be pruned and the nonce can be replayed within its
108    /// remaining validity window.
109    fn reserve(&self, nonce_id: &str) -> Result<bool, KernelError>;
110
111    /// Reserve a nonce while telling the store when the nonce stops
112    /// being cryptographically valid. Durable implementations (SQLite,
113    /// remote KV stores) MUST retain the consumed marker until at least
114    /// `nonce_expires_at` so replay protection covers the nonce's full
115    /// validity window.
116    ///
117    /// The default implementation falls back to [`Self::reserve`] for
118    /// in-memory / best-effort stores that already track retention
119    /// internally. `nonce_expires_at` is wall-clock unix seconds.
120    fn reserve_until(&self, nonce_id: &str, _nonce_expires_at: i64) -> Result<bool, KernelError> {
121        self.reserve(nonce_id)
122    }
123
124    fn is_consumed(&self, _nonce_id: &str) -> Result<bool, KernelError> {
125        Ok(false)
126    }
127}
128
129// ---------------------------------------------------------------------------
130// InMemoryExecutionNonceStore
131// ---------------------------------------------------------------------------
132
133/// In-memory LRU-backed execution nonce store.
134///
135/// Mirrors the shape of `dpop::DpopNonceStore` but keys on the nonce_id
136/// alone because the full binding lives inside the signed body and is
137/// checked separately by `verify_execution_nonce`.
138pub struct InMemoryExecutionNonceStore {
139    inner: Mutex<LruCache<String, Instant>>,
140    ttl: Duration,
141}
142
143impl InMemoryExecutionNonceStore {
144    /// Create a new in-memory store.
145    ///
146    /// `capacity` is the maximum number of recently consumed nonces to
147    /// remember. `ttl` is how long a nonce entry is retained when callers use
148    /// the legacy `reserve` path. `reserve_until` extends retention to cover
149    /// the signed nonce validity window.
150    #[must_use]
151    pub fn new(capacity: usize, ttl: Duration) -> Self {
152        let nz = NonZeroUsize::new(capacity).unwrap_or_else(|| {
153            NonZeroUsize::new(DEFAULT_EXECUTION_NONCE_STORE_CAPACITY).unwrap_or(NonZeroUsize::MIN)
154        });
155        Self {
156            inner: Mutex::new(LruCache::new(nz)),
157            ttl,
158        }
159    }
160
161    /// Build a store with the TTL and capacity from `config`.
162    #[must_use]
163    pub fn from_config(config: &ExecutionNonceConfig) -> Self {
164        Self::new(
165            config.nonce_store_capacity,
166            Duration::from_secs(config.nonce_ttl_secs),
167        )
168    }
169}
170
171impl Default for InMemoryExecutionNonceStore {
172    fn default() -> Self {
173        Self::new(
174            DEFAULT_EXECUTION_NONCE_STORE_CAPACITY,
175            Duration::from_secs(DEFAULT_EXECUTION_NONCE_TTL_SECS),
176        )
177    }
178}
179
180impl ExecutionNonceStore for InMemoryExecutionNonceStore {
181    fn reserve(&self, nonce_id: &str) -> Result<bool, KernelError> {
182        self.reserve_with_retention(nonce_id, self.ttl)
183    }
184
185    fn reserve_until(&self, nonce_id: &str, nonce_expires_at: i64) -> Result<bool, KernelError> {
186        let retention = duration_until_unix_secs(nonce_expires_at)
187            .map_or(self.ttl, |remaining| remaining.max(self.ttl));
188        self.reserve_with_retention(nonce_id, retention)
189    }
190
191    fn is_consumed(&self, nonce_id: &str) -> Result<bool, KernelError> {
192        let cache = self.inner.lock().map_err(|_| {
193            error!("execution nonce store mutex poisoned; denying fail-closed");
194            KernelError::Internal("execution nonce store mutex poisoned; fail-closed".to_string())
195        })?;
196        let now = Instant::now();
197        Ok(cache
198            .peek(nonce_id)
199            .is_some_and(|retain_until| *retain_until > now))
200    }
201}
202
203impl InMemoryExecutionNonceStore {
204    fn reserve_with_retention(
205        &self,
206        nonce_id: &str,
207        retention: Duration,
208    ) -> Result<bool, KernelError> {
209        let mut cache = self.inner.lock().map_err(|_| {
210            error!("execution nonce store mutex poisoned; denying fail-closed");
211            KernelError::Internal("execution nonce store mutex poisoned; fail-closed".to_string())
212        })?;
213
214        let key = nonce_id.to_string();
215        let now = Instant::now();
216        if let Some(retain_until) = cache.peek(&key) {
217            if *retain_until > now {
218                return Ok(false);
219            }
220            cache.pop(&key);
221        }
222        let expired: Vec<String> = cache
223            .iter()
224            .filter(|(_, retain_until)| **retain_until <= now)
225            .map(|(nonce_id, _)| nonce_id.clone())
226            .collect();
227        for nonce_id in expired {
228            cache.pop(&nonce_id);
229        }
230        if cache.len() >= cache.cap().get() {
231            error!("execution nonce store capacity exhausted; denying fail-closed");
232            return Err(KernelError::Internal(
233                "execution nonce store capacity exhausted; fail-closed".to_string(),
234            ));
235        }
236        let Some(retain_until) = now.checked_add(retention) else {
237            error!("execution nonce retention overflow; denying fail-closed");
238            return Err(KernelError::Internal(
239                "execution nonce retention overflow; fail-closed".to_string(),
240            ));
241        };
242        cache.put(key, retain_until);
243        Ok(true)
244    }
245}
246
247fn duration_until_unix_secs(expires_at: i64) -> Option<Duration> {
248    let now = SystemTime::now()
249        .duration_since(UNIX_EPOCH)
250        .map(|duration| duration.as_secs())
251        .unwrap_or(0);
252    let expires_at = u64::try_from(expires_at).ok()?;
253    expires_at.checked_sub(now).map(Duration::from_secs)
254}
255
256// ---------------------------------------------------------------------------
257// Minting
258// ---------------------------------------------------------------------------
259
260/// Mint a fresh signed execution nonce.
261///
262/// The kernel calls this on every `Verdict::Allow` so tool servers can
263/// verify that a call was authorized by the kernel at a known, recent
264/// time. The returned nonce is signed by `kernel_keypair`; downstream
265/// verifiers check the signature with the kernel's public key.
266pub fn mint_execution_nonce(
267    kernel_keypair: &Keypair,
268    binding: NonceBinding,
269    config: &ExecutionNonceConfig,
270    now: i64,
271) -> Result<SignedExecutionNonce, KernelError> {
272    mint_execution_nonce_with_reservation(kernel_keypair, binding, None, None, config, now)
273}
274
275pub fn mint_execution_nonce_with_reservation(
276    kernel_keypair: &Keypair,
277    binding: NonceBinding,
278    reserved_hold_id: Option<String>,
279    reserving_request_id: Option<String>,
280    config: &ExecutionNonceConfig,
281    now: i64,
282) -> Result<SignedExecutionNonce, KernelError> {
283    let ttl = i64::try_from(config.nonce_ttl_secs).unwrap_or(i64::MAX);
284    let expires_at = now.saturating_add(ttl);
285    let nonce = ExecutionNonce {
286        schema: EXECUTION_NONCE_SCHEMA.to_string(),
287        nonce_id: Uuid::now_v7().as_hyphenated().to_string(),
288        issued_at: now,
289        expires_at,
290        bound_to: binding,
291        reserved_hold_id,
292        reserving_request_id,
293    };
294    let (signature, _bytes) = kernel_keypair.sign_canonical(&nonce).map_err(|e| {
295        KernelError::ReceiptSigningFailed(format!("failed to sign execution nonce: {e}"))
296    })?;
297    Ok(SignedExecutionNonce { nonce, signature })
298}
299
300// ---------------------------------------------------------------------------
301// Verification
302// ---------------------------------------------------------------------------
303
304/// All the reasons an execution nonce can fail verification.
305///
306/// Every variant is a hard deny on the kernel side. The nonce flow is
307/// fail-closed: schema, expiry, binding, signature, and replay checks all
308/// execute on every presented nonce and any failure short-circuits.
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub enum ExecutionNonceError {
311    /// Schema did not equal `EXECUTION_NONCE_SCHEMA`.
312    BadSchema { got: String },
313    /// Nonce has expired (now >= expires_at).
314    Expired { now: i64, expires_at: i64 },
315    /// Binding fields did not match the presented invocation.
316    BindingMismatch { field: &'static str },
317    /// Ed25519 signature did not verify under the kernel's public key.
318    InvalidSignature,
319    /// Nonce was already consumed (single-use).
320    Replayed,
321    /// Canonical JSON serialization failed during verification.
322    Encoding(String),
323    /// Replay store was unreachable; fail-closed.
324    Store(String),
325}
326
327impl std::fmt::Display for ExecutionNonceError {
328    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329        match self {
330            Self::BadSchema { got } => write!(
331                f,
332                "execution nonce has unsupported schema: expected {EXECUTION_NONCE_SCHEMA}, got {got}"
333            ),
334            Self::Expired { now, expires_at } => write!(
335                f,
336                "execution nonce expired (now={now}, expires_at={expires_at})"
337            ),
338            Self::BindingMismatch { field } => {
339                write!(f, "execution nonce binding mismatch on field {field}")
340            }
341            Self::InvalidSignature => write!(f, "execution nonce signature is invalid"),
342            Self::Replayed => write!(f, "execution nonce has already been consumed"),
343            Self::Encoding(e) => write!(f, "execution nonce canonical encoding failed: {e}"),
344            Self::Store(e) => write!(f, "execution nonce store error: {e}"),
345        }
346    }
347}
348
349impl std::error::Error for ExecutionNonceError {}
350
351impl From<ExecutionNonceError> for KernelError {
352    fn from(err: ExecutionNonceError) -> Self {
353        KernelError::Internal(format!("execution nonce verification failed: {err}"))
354    }
355}
356
357/// Verify a signed execution nonce against the expected binding.
358///
359/// Steps, in order:
360/// 1. Schema check.
361/// 2. Expiry check -- `now < nonce.expires_at`.
362/// 3. Binding check -- subject, capability, server, tool, parameter_hash.
363/// 4. Signature check -- canonical JSON under the kernel's pubkey.
364/// 5. Replay check -- `nonce_store.reserve(nonce_id)` must return `true`.
365pub fn verify_execution_nonce(
366    presented: &SignedExecutionNonce,
367    kernel_pubkey: &PublicKey,
368    expected: &NonceBinding,
369    now: i64,
370    nonce_store: &dyn ExecutionNonceStore,
371) -> Result<(), ExecutionNonceError> {
372    validate_execution_nonce(presented, kernel_pubkey, expected, now)?;
373    reserve_execution_nonce(presented, nonce_store, now)
374}
375
376pub fn verify_execution_nonce_without_consume(
377    presented: &SignedExecutionNonce,
378    kernel_pubkey: &PublicKey,
379    expected: &NonceBinding,
380    now: i64,
381    nonce_store: &dyn ExecutionNonceStore,
382) -> Result<(), ExecutionNonceError> {
383    validate_execution_nonce(presented, kernel_pubkey, expected, now)?;
384    if nonce_store
385        .is_consumed(&presented.nonce.nonce_id)
386        .map_err(|error| ExecutionNonceError::Store(error.to_string()))?
387    {
388        return Err(ExecutionNonceError::Replayed);
389    }
390    Ok(())
391}
392
393pub fn consume_execution_nonce(
394    nonce_store: &dyn ExecutionNonceStore,
395    nonce_id: &str,
396    nonce_expires_at: i64,
397) -> Result<(), ExecutionNonceError> {
398    match nonce_store.reserve_until(nonce_id, nonce_expires_at) {
399        Ok(true) => Ok(()),
400        Ok(false) => Err(ExecutionNonceError::Replayed),
401        Err(error) => Err(ExecutionNonceError::Store(error.to_string())),
402    }
403}
404
405/// Validate a signed execution nonce without consuming it in the replay store.
406pub(crate) fn validate_execution_nonce(
407    presented: &SignedExecutionNonce,
408    kernel_pubkey: &PublicKey,
409    expected: &NonceBinding,
410    now: i64,
411) -> Result<(), ExecutionNonceError> {
412    if !is_supported_execution_nonce_schema(&presented.nonce.schema) {
413        warn!(
414            schema = %presented.nonce.schema,
415            "rejecting execution nonce with unsupported schema"
416        );
417        return Err(ExecutionNonceError::BadSchema {
418            got: presented.nonce.schema.clone(),
419        });
420    }
421
422    if now >= presented.nonce.expires_at {
423        warn!(
424            nonce_id = %presented.nonce.nonce_id,
425            now,
426            expires_at = presented.nonce.expires_at,
427            "rejecting stale execution nonce"
428        );
429        return Err(ExecutionNonceError::Expired {
430            now,
431            expires_at: presented.nonce.expires_at,
432        });
433    }
434
435    let bound = &presented.nonce.bound_to;
436    if bound.subject_id != expected.subject_id {
437        return Err(ExecutionNonceError::BindingMismatch {
438            field: "subject_id",
439        });
440    }
441    if bound.request_id.is_empty() || bound.request_id != expected.request_id {
442        return Err(ExecutionNonceError::BindingMismatch {
443            field: "request_id",
444        });
445    }
446    if bound.capability_id != expected.capability_id {
447        return Err(ExecutionNonceError::BindingMismatch {
448            field: "capability_id",
449        });
450    }
451    if bound.tool_server != expected.tool_server {
452        return Err(ExecutionNonceError::BindingMismatch {
453            field: "tool_server",
454        });
455    }
456    if bound.tool_name != expected.tool_name {
457        return Err(ExecutionNonceError::BindingMismatch { field: "tool_name" });
458    }
459    if bound.parameter_hash != expected.parameter_hash {
460        return Err(ExecutionNonceError::BindingMismatch {
461            field: "parameter_hash",
462        });
463    }
464
465    let signed_bytes = canonical_json_bytes(&presented.nonce)
466        .map_err(|e| ExecutionNonceError::Encoding(e.to_string()))?;
467    if !kernel_pubkey.verify(&signed_bytes, &presented.signature) {
468        warn!(
469            nonce_id = %presented.nonce.nonce_id,
470            "execution nonce signature verification failed"
471        );
472        return Err(ExecutionNonceError::InvalidSignature);
473    }
474
475    Ok(())
476}
477
478/// Consume a previously validated execution nonce in the replay store.
479pub(crate) fn reserve_execution_nonce(
480    presented: &SignedExecutionNonce,
481    nonce_store: &dyn ExecutionNonceStore,
482    now: i64,
483) -> Result<(), ExecutionNonceError> {
484    if now >= presented.nonce.expires_at {
485        return Err(ExecutionNonceError::Expired {
486            now,
487            expires_at: presented.nonce.expires_at,
488        });
489    }
490    // Pass the nonce's signed expiry so durable stores retain the
491    // consumed marker for the full validity window - otherwise the row
492    // can be pruned while the nonce is still cryptographically valid,
493    // allowing replay within the remaining window.
494    match nonce_store.reserve_until(&presented.nonce.nonce_id, presented.nonce.expires_at) {
495        Ok(true) => Ok(()),
496        Ok(false) => {
497            warn!(
498                nonce_id = %presented.nonce.nonce_id,
499                "rejecting replayed execution nonce"
500            );
501            Err(ExecutionNonceError::Replayed)
502        }
503        Err(e) => Err(ExecutionNonceError::Store(e.to_string())),
504    }
505}
506
507#[cfg(test)]
508#[allow(clippy::expect_used, clippy::unwrap_used)]
509mod tests {
510    use super::*;
511    use std::thread;
512
513    fn sample_binding() -> NonceBinding {
514        NonceBinding {
515            subject_id: "subject-abc".to_string(),
516            request_id: "request-abc".to_string(),
517            capability_id: "cap-123".to_string(),
518            tool_server: "fs".to_string(),
519            tool_name: "read_file".to_string(),
520            parameter_hash: "0000000000000000000000000000000000000000000000000000000000000000"
521                .to_string(),
522        }
523    }
524
525    #[test]
526    fn mint_then_verify_roundtrip() {
527        let kp = Keypair::generate();
528        let store = InMemoryExecutionNonceStore::default();
529        let cfg = ExecutionNonceConfig::default();
530        let binding = sample_binding();
531        let now = 1_000_000;
532
533        let signed = mint_execution_nonce(&kp, binding.clone(), &cfg, now).unwrap();
534        assert_eq!(signed.nonce.schema, EXECUTION_NONCE_SCHEMA);
535        assert_eq!(signed.nonce.expires_at, now + cfg.nonce_ttl_secs as i64);
536
537        verify_execution_nonce(&signed, &kp.public_key(), &binding, now + 1, &store).unwrap();
538    }
539
540    #[test]
541    fn stale_nonce_is_rejected() {
542        let kp = Keypair::generate();
543        let store = InMemoryExecutionNonceStore::default();
544        let cfg = ExecutionNonceConfig::default();
545        let binding = sample_binding();
546
547        let now = 1_000_000;
548        let signed = mint_execution_nonce(&kp, binding.clone(), &cfg, now).unwrap();
549        let err = verify_execution_nonce(
550            &signed,
551            &kp.public_key(),
552            &binding,
553            now + cfg.nonce_ttl_secs as i64 + 1,
554            &store,
555        )
556        .unwrap_err();
557        assert!(matches!(err, ExecutionNonceError::Expired { .. }));
558    }
559
560    #[test]
561    fn nonce_expiry_is_rechecked_when_reserved() {
562        let kp = Keypair::generate();
563        let store = InMemoryExecutionNonceStore::default();
564        let cfg = ExecutionNonceConfig::default();
565        let binding = sample_binding();
566        let now = 1_000_000;
567        let signed = mint_execution_nonce(&kp, binding.clone(), &cfg, now).unwrap();
568        validate_execution_nonce(&signed, &kp.public_key(), &binding, now + 1).unwrap();
569
570        let error = reserve_execution_nonce(&signed, &store, signed.nonce.expires_at).unwrap_err();
571
572        assert!(matches!(error, ExecutionNonceError::Expired { .. }));
573        assert!(!store.is_consumed(signed.nonce_id()).unwrap());
574    }
575
576    #[test]
577    fn replayed_nonce_is_rejected() {
578        let kp = Keypair::generate();
579        let store = InMemoryExecutionNonceStore::default();
580        let cfg = ExecutionNonceConfig::default();
581        let binding = sample_binding();
582        let now = 1_000_000;
583
584        let signed = mint_execution_nonce(&kp, binding.clone(), &cfg, now).unwrap();
585        verify_execution_nonce(&signed, &kp.public_key(), &binding, now + 1, &store).unwrap();
586        let err = verify_execution_nonce(&signed, &kp.public_key(), &binding, now + 2, &store)
587            .unwrap_err();
588        assert!(matches!(err, ExecutionNonceError::Replayed));
589    }
590
591    #[test]
592    fn nonce_without_request_binding_is_rejected() {
593        let kp = Keypair::generate();
594        let cfg = ExecutionNonceConfig::default();
595        let binding = sample_binding();
596        let now = 1_000_000;
597        let signed = mint_execution_nonce(&kp, binding.clone(), &cfg, now).unwrap();
598        let mut encoded = serde_json::to_value(signed).unwrap();
599        encoded["nonce"]["bound_to"]
600            .as_object_mut()
601            .unwrap()
602            .remove("request_id");
603        let decoded: SignedExecutionNonce = serde_json::from_value(encoded).unwrap();
604
605        assert!(decoded.nonce.bound_to.request_id.is_empty());
606        let error =
607            validate_execution_nonce(&decoded, &kp.public_key(), &binding, now + 1).unwrap_err();
608
609        assert!(matches!(
610            error,
611            ExecutionNonceError::BindingMismatch {
612                field: "request_id"
613            }
614        ));
615    }
616
617    #[test]
618    fn mismatched_binding_is_rejected() {
619        let kp = Keypair::generate();
620        let store = InMemoryExecutionNonceStore::default();
621        let cfg = ExecutionNonceConfig::default();
622        let minted_binding = sample_binding();
623        let now = 1_000_000;
624
625        let signed = mint_execution_nonce(&kp, minted_binding.clone(), &cfg, now).unwrap();
626        let mut wrong = minted_binding;
627        wrong.tool_name = "write_file".to_string();
628
629        let err =
630            verify_execution_nonce(&signed, &kp.public_key(), &wrong, now + 1, &store).unwrap_err();
631        assert!(matches!(
632            err,
633            ExecutionNonceError::BindingMismatch { field: "tool_name" }
634        ));
635    }
636
637    #[test]
638    fn tampered_signature_is_rejected() {
639        let kp = Keypair::generate();
640        let store = InMemoryExecutionNonceStore::default();
641        let cfg = ExecutionNonceConfig::default();
642        let binding = sample_binding();
643        let now = 1_000_000;
644
645        let mut signed = mint_execution_nonce(&kp, binding.clone(), &cfg, now).unwrap();
646        // Mutate a signed field without re-signing: signature must no longer verify.
647        signed.nonce.bound_to.tool_name = "write_file".to_string();
648        // Revert the binding mismatch check by also mutating the presented binding.
649        let mut expected = binding;
650        expected.tool_name = "write_file".to_string();
651
652        let err = verify_execution_nonce(&signed, &kp.public_key(), &expected, now + 1, &store)
653            .unwrap_err();
654        assert!(matches!(err, ExecutionNonceError::InvalidSignature));
655    }
656
657    #[test]
658    fn store_reserves_each_nonce_exactly_once() {
659        let store = InMemoryExecutionNonceStore::default();
660        assert!(store.reserve("a").unwrap());
661        assert!(!store.reserve("a").unwrap());
662        assert!(store.reserve("b").unwrap());
663    }
664
665    #[test]
666    fn reserve_until_retains_nonce_after_local_ttl() {
667        let store = InMemoryExecutionNonceStore::new(16, Duration::from_millis(1));
668        let expires_at = SystemTime::now()
669            .duration_since(UNIX_EPOCH)
670            .unwrap()
671            .as_secs()
672            .saturating_add(30);
673        let expires_at = i64::try_from(expires_at).unwrap();
674
675        assert!(store.reserve_until("long-lived", expires_at).unwrap());
676        thread::sleep(Duration::from_millis(5));
677        assert!(!store.reserve_until("long-lived", expires_at).unwrap());
678    }
679
680    #[test]
681    fn capacity_exhaustion_preserves_live_replay_markers() {
682        let store = InMemoryExecutionNonceStore::new(1, Duration::from_secs(30));
683
684        assert!(store.reserve("first").unwrap());
685        let error = store.reserve("second").unwrap_err();
686        assert!(matches!(
687            error,
688            KernelError::Internal(reason)
689                if reason.contains("execution nonce store capacity exhausted")
690        ));
691        assert!(!store.reserve("first").unwrap());
692    }
693
694    #[test]
695    fn reserve_with_retention_fails_closed_on_overflow() {
696        let store = InMemoryExecutionNonceStore::default();
697        let err = store
698            .reserve_with_retention("overflow", Duration::MAX)
699            .unwrap_err();
700
701        assert!(matches!(
702            err,
703            KernelError::Internal(reason)
704                if reason.contains("execution nonce retention overflow")
705        ));
706    }
707
708    #[test]
709    fn store_does_not_stall_between_threads() {
710        let store = std::sync::Arc::new(InMemoryExecutionNonceStore::default());
711        let mut handles = Vec::new();
712        for i in 0..4 {
713            let store = std::sync::Arc::clone(&store);
714            handles.push(thread::spawn(move || {
715                let id = format!("t-{i}");
716                store.reserve(&id).unwrap()
717            }));
718        }
719        for h in handles {
720            assert!(h.join().unwrap());
721        }
722    }
723}