use std::num::NonZeroUsize;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use chio_core::canonical::canonical_json_bytes;
use chio_core::crypto::{Keypair, PublicKey, Signature};
use lru::LruCache;
use serde::{Deserialize, Serialize};
use tracing::{error, warn};
use uuid::Uuid;
use crate::KernelError;
pub const EXECUTION_NONCE_SCHEMA: &str = "chio.execution_nonce.v1";
pub const DEFAULT_EXECUTION_NONCE_TTL_SECS: u64 = 30;
pub const DEFAULT_EXECUTION_NONCE_STORE_CAPACITY: usize = 16_384;
#[must_use]
pub fn is_supported_execution_nonce_schema(schema: &str) -> bool {
schema == EXECUTION_NONCE_SCHEMA
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NonceBinding {
pub subject_id: String,
pub capability_id: String,
pub tool_server: String,
pub tool_name: String,
pub parameter_hash: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionNonce {
pub schema: String,
pub nonce_id: String,
pub issued_at: i64,
pub expires_at: i64,
pub bound_to: NonceBinding,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedExecutionNonce {
pub nonce: ExecutionNonce,
pub signature: Signature,
}
impl SignedExecutionNonce {
#[must_use]
pub fn nonce_id(&self) -> &str {
&self.nonce.nonce_id
}
#[must_use]
pub fn expires_at(&self) -> i64 {
self.nonce.expires_at
}
}
#[derive(Debug, Clone)]
pub struct ExecutionNonceConfig {
pub nonce_ttl_secs: u64,
pub nonce_store_capacity: usize,
pub require_nonce: bool,
}
impl Default for ExecutionNonceConfig {
fn default() -> Self {
Self {
nonce_ttl_secs: DEFAULT_EXECUTION_NONCE_TTL_SECS,
nonce_store_capacity: DEFAULT_EXECUTION_NONCE_STORE_CAPACITY,
require_nonce: false,
}
}
}
pub trait ExecutionNonceStore: Send + Sync {
fn reserve(&self, nonce_id: &str) -> Result<bool, KernelError>;
fn reserve_until(&self, nonce_id: &str, _nonce_expires_at: i64) -> Result<bool, KernelError> {
self.reserve(nonce_id)
}
}
pub struct InMemoryExecutionNonceStore {
inner: Mutex<LruCache<String, Instant>>,
ttl: Duration,
}
impl InMemoryExecutionNonceStore {
#[must_use]
pub fn new(capacity: usize, ttl: Duration) -> Self {
let nz = NonZeroUsize::new(capacity).unwrap_or_else(|| {
NonZeroUsize::new(DEFAULT_EXECUTION_NONCE_STORE_CAPACITY).unwrap_or(NonZeroUsize::MIN)
});
Self {
inner: Mutex::new(LruCache::new(nz)),
ttl,
}
}
#[must_use]
pub fn from_config(config: &ExecutionNonceConfig) -> Self {
Self::new(
config.nonce_store_capacity,
Duration::from_secs(config.nonce_ttl_secs),
)
}
}
impl Default for InMemoryExecutionNonceStore {
fn default() -> Self {
Self::new(
DEFAULT_EXECUTION_NONCE_STORE_CAPACITY,
Duration::from_secs(DEFAULT_EXECUTION_NONCE_TTL_SECS),
)
}
}
impl ExecutionNonceStore for InMemoryExecutionNonceStore {
fn reserve(&self, nonce_id: &str) -> Result<bool, KernelError> {
let mut cache = self.inner.lock().map_err(|_| {
error!("execution nonce store mutex poisoned; denying fail-closed");
KernelError::Internal("execution nonce store mutex poisoned; fail-closed".to_string())
})?;
let key = nonce_id.to_string();
if let Some(consumed_at) = cache.peek(&key) {
if consumed_at.elapsed() < self.ttl {
return Ok(false);
}
cache.pop(&key);
}
cache.put(key, Instant::now());
Ok(true)
}
}
pub fn mint_execution_nonce(
kernel_keypair: &Keypair,
binding: NonceBinding,
config: &ExecutionNonceConfig,
now: i64,
) -> Result<SignedExecutionNonce, KernelError> {
let ttl = i64::try_from(config.nonce_ttl_secs).unwrap_or(i64::MAX);
let expires_at = now.saturating_add(ttl);
let nonce = ExecutionNonce {
schema: EXECUTION_NONCE_SCHEMA.to_string(),
nonce_id: Uuid::now_v7().as_hyphenated().to_string(),
issued_at: now,
expires_at,
bound_to: binding,
};
let (signature, _bytes) = kernel_keypair.sign_canonical(&nonce).map_err(|e| {
KernelError::ReceiptSigningFailed(format!("failed to sign execution nonce: {e}"))
})?;
Ok(SignedExecutionNonce { nonce, signature })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExecutionNonceError {
BadSchema { got: String },
Expired { now: i64, expires_at: i64 },
BindingMismatch { field: &'static str },
InvalidSignature,
Replayed,
Encoding(String),
Store(String),
}
impl std::fmt::Display for ExecutionNonceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BadSchema { got } => write!(
f,
"execution nonce has unsupported schema: expected {EXECUTION_NONCE_SCHEMA}, got {got}"
),
Self::Expired { now, expires_at } => write!(
f,
"execution nonce expired (now={now}, expires_at={expires_at})"
),
Self::BindingMismatch { field } => {
write!(f, "execution nonce binding mismatch on field {field}")
}
Self::InvalidSignature => write!(f, "execution nonce signature is invalid"),
Self::Replayed => write!(f, "execution nonce has already been consumed"),
Self::Encoding(e) => write!(f, "execution nonce canonical encoding failed: {e}"),
Self::Store(e) => write!(f, "execution nonce store error: {e}"),
}
}
}
impl std::error::Error for ExecutionNonceError {}
impl From<ExecutionNonceError> for KernelError {
fn from(err: ExecutionNonceError) -> Self {
KernelError::Internal(format!("execution nonce verification failed: {err}"))
}
}
pub fn verify_execution_nonce(
presented: &SignedExecutionNonce,
kernel_pubkey: &PublicKey,
expected: &NonceBinding,
now: i64,
nonce_store: &dyn ExecutionNonceStore,
) -> Result<(), ExecutionNonceError> {
if !is_supported_execution_nonce_schema(&presented.nonce.schema) {
warn!(
schema = %presented.nonce.schema,
"rejecting execution nonce with unsupported schema"
);
return Err(ExecutionNonceError::BadSchema {
got: presented.nonce.schema.clone(),
});
}
if now >= presented.nonce.expires_at {
warn!(
nonce_id = %presented.nonce.nonce_id,
now,
expires_at = presented.nonce.expires_at,
"rejecting stale execution nonce"
);
return Err(ExecutionNonceError::Expired {
now,
expires_at: presented.nonce.expires_at,
});
}
let bound = &presented.nonce.bound_to;
if bound.subject_id != expected.subject_id {
return Err(ExecutionNonceError::BindingMismatch {
field: "subject_id",
});
}
if bound.capability_id != expected.capability_id {
return Err(ExecutionNonceError::BindingMismatch {
field: "capability_id",
});
}
if bound.tool_server != expected.tool_server {
return Err(ExecutionNonceError::BindingMismatch {
field: "tool_server",
});
}
if bound.tool_name != expected.tool_name {
return Err(ExecutionNonceError::BindingMismatch { field: "tool_name" });
}
if bound.parameter_hash != expected.parameter_hash {
return Err(ExecutionNonceError::BindingMismatch {
field: "parameter_hash",
});
}
let signed_bytes = canonical_json_bytes(&presented.nonce)
.map_err(|e| ExecutionNonceError::Encoding(e.to_string()))?;
if !kernel_pubkey.verify(&signed_bytes, &presented.signature) {
warn!(
nonce_id = %presented.nonce.nonce_id,
"execution nonce signature verification failed"
);
return Err(ExecutionNonceError::InvalidSignature);
}
match nonce_store.reserve_until(&presented.nonce.nonce_id, presented.nonce.expires_at) {
Ok(true) => Ok(()),
Ok(false) => {
warn!(
nonce_id = %presented.nonce.nonce_id,
"rejecting replayed execution nonce"
);
Err(ExecutionNonceError::Replayed)
}
Err(e) => Err(ExecutionNonceError::Store(e.to_string())),
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use std::thread;
fn sample_binding() -> NonceBinding {
NonceBinding {
subject_id: "subject-abc".to_string(),
capability_id: "cap-123".to_string(),
tool_server: "fs".to_string(),
tool_name: "read_file".to_string(),
parameter_hash: "0000000000000000000000000000000000000000000000000000000000000000"
.to_string(),
}
}
#[test]
fn mint_then_verify_roundtrip() {
let kp = Keypair::generate();
let store = InMemoryExecutionNonceStore::default();
let cfg = ExecutionNonceConfig::default();
let binding = sample_binding();
let now = 1_000_000;
let signed = mint_execution_nonce(&kp, binding.clone(), &cfg, now).unwrap();
assert_eq!(signed.nonce.schema, EXECUTION_NONCE_SCHEMA);
assert_eq!(signed.nonce.expires_at, now + cfg.nonce_ttl_secs as i64);
verify_execution_nonce(&signed, &kp.public_key(), &binding, now + 1, &store).unwrap();
}
#[test]
fn stale_nonce_is_rejected() {
let kp = Keypair::generate();
let store = InMemoryExecutionNonceStore::default();
let cfg = ExecutionNonceConfig::default();
let binding = sample_binding();
let now = 1_000_000;
let signed = mint_execution_nonce(&kp, binding.clone(), &cfg, now).unwrap();
let err = verify_execution_nonce(
&signed,
&kp.public_key(),
&binding,
now + cfg.nonce_ttl_secs as i64 + 1,
&store,
)
.unwrap_err();
assert!(matches!(err, ExecutionNonceError::Expired { .. }));
}
#[test]
fn replayed_nonce_is_rejected() {
let kp = Keypair::generate();
let store = InMemoryExecutionNonceStore::default();
let cfg = ExecutionNonceConfig::default();
let binding = sample_binding();
let now = 1_000_000;
let signed = mint_execution_nonce(&kp, binding.clone(), &cfg, now).unwrap();
verify_execution_nonce(&signed, &kp.public_key(), &binding, now + 1, &store).unwrap();
let err = verify_execution_nonce(&signed, &kp.public_key(), &binding, now + 2, &store)
.unwrap_err();
assert!(matches!(err, ExecutionNonceError::Replayed));
}
#[test]
fn mismatched_binding_is_rejected() {
let kp = Keypair::generate();
let store = InMemoryExecutionNonceStore::default();
let cfg = ExecutionNonceConfig::default();
let minted_binding = sample_binding();
let now = 1_000_000;
let signed = mint_execution_nonce(&kp, minted_binding.clone(), &cfg, now).unwrap();
let mut wrong = minted_binding;
wrong.tool_name = "write_file".to_string();
let err =
verify_execution_nonce(&signed, &kp.public_key(), &wrong, now + 1, &store).unwrap_err();
assert!(matches!(
err,
ExecutionNonceError::BindingMismatch { field: "tool_name" }
));
}
#[test]
fn tampered_signature_is_rejected() {
let kp = Keypair::generate();
let store = InMemoryExecutionNonceStore::default();
let cfg = ExecutionNonceConfig::default();
let binding = sample_binding();
let now = 1_000_000;
let mut signed = mint_execution_nonce(&kp, binding.clone(), &cfg, now).unwrap();
signed.nonce.bound_to.tool_name = "write_file".to_string();
let mut expected = binding;
expected.tool_name = "write_file".to_string();
let err = verify_execution_nonce(&signed, &kp.public_key(), &expected, now + 1, &store)
.unwrap_err();
assert!(matches!(err, ExecutionNonceError::InvalidSignature));
}
#[test]
fn store_reserves_each_nonce_exactly_once() {
let store = InMemoryExecutionNonceStore::default();
assert!(store.reserve("a").unwrap());
assert!(!store.reserve("a").unwrap());
assert!(store.reserve("b").unwrap());
}
#[test]
fn store_does_not_stall_between_threads() {
let store = std::sync::Arc::new(InMemoryExecutionNonceStore::default());
let mut handles = Vec::new();
for i in 0..4 {
let store = std::sync::Arc::clone(&store);
handles.push(thread::spawn(move || {
let id = format!("t-{i}");
store.reserve(&id).unwrap()
}));
}
for h in handles {
assert!(h.join().unwrap());
}
}
}