use std::num::NonZeroUsize;
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use chio_core::canonical::canonical_json_bytes;
use chio_core::capability::CapabilityToken;
use chio_core::crypto::{
sign_canonical_with_backend, Keypair, PublicKey, Signature, SigningBackend,
};
use lru::LruCache;
use serde::{Deserialize, Serialize};
use tracing::error;
use crate::KernelError;
pub const DPOP_SCHEMA: &str = "chio.dpop_proof.v1";
#[must_use]
pub fn is_supported_dpop_schema(schema: &str) -> bool {
schema == DPOP_SCHEMA
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DpopProofBody {
pub schema: String,
pub capability_id: String,
pub tool_server: String,
pub tool_name: String,
pub action_hash: String,
pub nonce: String,
pub issued_at: u64,
pub agent_key: PublicKey,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DpopProof {
pub body: DpopProofBody,
pub signature: Signature,
}
impl DpopProof {
pub fn sign(body: DpopProofBody, keypair: &Keypair) -> Result<DpopProof, KernelError> {
let body_bytes = canonical_json_bytes(&body).map_err(|e| {
KernelError::DpopVerificationFailed(format!("failed to serialize proof body: {e}"))
})?;
let signature = keypair.sign(&body_bytes);
Ok(DpopProof { body, signature })
}
pub fn sign_with_backend(
body: DpopProofBody,
backend: &dyn SigningBackend,
) -> Result<DpopProof, KernelError> {
let (signature, _bytes) = sign_canonical_with_backend(backend, &body).map_err(|e| {
KernelError::DpopVerificationFailed(format!("failed to sign proof body: {e}"))
})?;
Ok(DpopProof { body, signature })
}
}
#[derive(Debug, Clone)]
pub struct DpopConfig {
pub proof_ttl_secs: u64,
pub max_clock_skew_secs: u64,
pub nonce_store_capacity: usize,
}
impl Default for DpopConfig {
fn default() -> Self {
Self {
proof_ttl_secs: 300,
max_clock_skew_secs: 30,
nonce_store_capacity: 8192,
}
}
}
pub struct DpopNonceStore {
inner: Mutex<LruCache<(String, String), Instant>>,
ttl: Duration,
}
impl DpopNonceStore {
pub fn new(capacity: usize, ttl: Duration) -> Self {
let nz = NonZeroUsize::new(capacity).unwrap_or_else(|| {
NonZeroUsize::new(1024).unwrap_or(NonZeroUsize::MIN)
});
Self {
inner: Mutex::new(LruCache::new(nz)),
ttl,
}
}
pub fn check_and_insert(&self, nonce: &str, capability_id: &str) -> Result<bool, KernelError> {
let key = (nonce.to_string(), capability_id.to_string());
let mut cache = self.inner.lock().map_err(|_| {
error!("DPoP nonce store mutex is poisoned; denying proof as fail-closed");
KernelError::DpopVerificationFailed(
"nonce store mutex poisoned; cannot verify replay safety".to_string(),
)
})?;
if let Some(first_seen) = cache.peek(&key) {
if first_seen.elapsed() < self.ttl {
return Ok(false);
}
cache.pop(&key);
}
cache.put(key, Instant::now());
Ok(true)
}
}
pub fn verify_dpop_proof(
proof: &DpopProof,
capability: &CapabilityToken,
expected_tool_server: &str,
expected_tool_name: &str,
expected_action_hash: &str,
nonce_store: &DpopNonceStore,
config: &DpopConfig,
) -> Result<(), KernelError> {
if !is_supported_dpop_schema(&proof.body.schema) {
return Err(KernelError::DpopVerificationFailed(format!(
"unknown DPoP schema: expected {DPOP_SCHEMA}, got {}",
proof.body.schema
)));
}
if proof.body.agent_key != capability.subject {
return Err(KernelError::DpopVerificationFailed(
"agent_key does not match capability subject (sender constraint violated)".to_string(),
));
}
if proof.body.capability_id != capability.id
|| proof.body.tool_server != expected_tool_server
|| proof.body.tool_name != expected_tool_name
|| proof.body.action_hash != expected_action_hash
{
return Err(KernelError::DpopVerificationFailed(
"binding fields do not match: capability_id, tool_server, tool_name, or action_hash mismatch".to_string(),
));
}
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if proof.body.issued_at > now_secs.saturating_add(config.max_clock_skew_secs) {
return Err(KernelError::DpopVerificationFailed(format!(
"proof issued_at={} is too far in the future (now={}, skew={})",
proof.body.issued_at, now_secs, config.max_clock_skew_secs
)));
}
if proof.body.issued_at.saturating_add(config.proof_ttl_secs) < now_secs {
return Err(KernelError::DpopVerificationFailed(format!(
"proof expired: issued_at={} ttl={} now={}",
proof.body.issued_at, config.proof_ttl_secs, now_secs
)));
}
let stale_threshold =
now_secs.saturating_sub(config.proof_ttl_secs + config.max_clock_skew_secs);
if proof.body.issued_at < stale_threshold {
return Err(KernelError::DpopVerificationFailed(format!(
"proof issued_at={} is too far in the past (now={}, ttl={}, skew={})",
proof.body.issued_at, now_secs, config.proof_ttl_secs, config.max_clock_skew_secs
)));
}
let body_bytes = canonical_json_bytes(&proof.body).map_err(|e| {
KernelError::DpopVerificationFailed(format!("failed to serialize proof body: {e}"))
})?;
if !proof.body.agent_key.verify(&body_bytes, &proof.signature) {
return Err(KernelError::DpopVerificationFailed(
"proof signature verification failed".to_string(),
));
}
if !nonce_store.check_and_insert(&proof.body.nonce, &proof.body.capability_id)? {
return Err(KernelError::DpopVerificationFailed(
"nonce replayed: this nonce has already been used within the TTL window".to_string(),
));
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod backend_tests {
use super::*;
use chio_core::crypto::Ed25519Backend;
#[test]
fn ed25519_backend_produces_equivalent_dpop_proof() {
let kp = Keypair::generate();
let backend = Ed25519Backend::new(kp.clone());
let body = DpopProofBody {
schema: DPOP_SCHEMA.to_string(),
capability_id: "cap-1".to_string(),
tool_server: "srv".to_string(),
tool_name: "tool".to_string(),
action_hash: "hash".to_string(),
nonce: "nonce-1".to_string(),
issued_at: 1_000,
agent_key: kp.public_key(),
};
let proof = DpopProof::sign_with_backend(body.clone(), &backend).unwrap();
let bytes = canonical_json_bytes(&proof.body).unwrap();
assert!(proof.body.agent_key.verify(&bytes, &proof.signature));
}
}