1use 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
47pub const EXECUTION_NONCE_SCHEMA: &str = "chio.execution_nonce.v1";
49
50pub const DEFAULT_EXECUTION_NONCE_TTL_SECS: u64 = 30;
52
53pub 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#[derive(Debug, Clone)]
67pub struct ExecutionNonceConfig {
68 pub nonce_ttl_secs: u64,
70 pub nonce_store_capacity: usize,
72 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
87pub trait ExecutionNonceStore: Send + Sync {
98 fn reserve(&self, nonce_id: &str) -> Result<bool, KernelError>;
110
111 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
129pub struct InMemoryExecutionNonceStore {
139 inner: Mutex<LruCache<String, Instant>>,
140 ttl: Duration,
141}
142
143impl InMemoryExecutionNonceStore {
144 #[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 #[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
256pub 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#[derive(Debug, Clone, PartialEq, Eq)]
310pub enum ExecutionNonceError {
311 BadSchema { got: String },
313 Expired { now: i64, expires_at: i64 },
315 BindingMismatch { field: &'static str },
317 InvalidSignature,
319 Replayed,
321 Encoding(String),
323 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
357pub 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
405pub(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
478pub(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 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 signed.nonce.bound_to.tool_name = "write_file".to_string();
648 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}