Skip to main content

chio_kernel/
dpop.rs

1//! DPoP (Demonstration of Proof-of-Possession) for Chio tool invocations.
2//!
3//! A DPoP proof is a signed canonical JSON object that binds a single tool
4//! invocation to the agent's keypair. It prevents stolen-token replay by
5//! requiring the agent to prove possession of the private key corresponding
6//! to `capability.subject` on every invocation.
7//!
8//! Proof fields:
9//! - `schema`:        constant `"chio.dpop_proof.v1"`
10//! - `capability_id`: token ID of the capability being invoked
11//! - `tool_server`:   server_id of the target tool server
12//! - `tool_name`:     name of the tool being called
13//! - `action_hash`:   SHA-256 hash of the serialized tool arguments
14//! - `nonce`:         caller-chosen random string (replay prevention)
15//! - `issued_at`:     Unix seconds when the proof was created
16//! - `agent_key`:     hex-encoded public key of the signer (Ed25519 by default;
17//!   `p256:` / `p384:` prefix under the FIPS crypto path)
18//!
19//! Verification steps (in order):
20//! 1. Schema check -- must equal `DPOP_SCHEMA`
21//! 2. Sender constraint -- `agent_key` must equal `capability.subject`
22//! 3. Binding fields -- capability_id, tool_server, tool_name, action_hash all match
23//! 4. Freshness -- `issued_at + proof_ttl_secs >= now` and `issued_at <= now + max_clock_skew_secs`
24//! 5. Signature -- verified through the signing backend negotiated between
25//!    agent and kernel; dispatches off the algorithm carried by `agent_key`
26//!    and the proof's `signature` field
27//! 6. Nonce replay -- nonce must not have been seen within the TTL window
28
29use std::num::NonZeroUsize;
30use std::sync::Mutex;
31use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
32
33use chio_core::canonical::canonical_json_bytes;
34use chio_core::capability::CapabilityToken;
35use chio_core::crypto::{
36    sign_canonical_with_backend, Keypair, PublicKey, Signature, SigningBackend,
37};
38use lru::LruCache;
39use serde::{Deserialize, Serialize};
40use tracing::error;
41
42use crate::KernelError;
43
44/// Schema identifier for Chio DPoP proofs.
45pub const DPOP_SCHEMA: &str = "chio.dpop_proof.v1";
46
47#[must_use]
48pub fn is_supported_dpop_schema(schema: &str) -> bool {
49    schema == DPOP_SCHEMA
50}
51
52// ---------------------------------------------------------------------------
53// DpopProofBody
54// ---------------------------------------------------------------------------
55
56/// The signable body of a DPoP proof.
57///
58/// This is the canonical-JSON-serialized message that the agent signs.
59/// All fields are included in the signature; none are mutable after signing.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct DpopProofBody {
62    /// Schema identifier. Must equal `DPOP_SCHEMA`.
63    pub schema: String,
64    /// ID of the capability token being used for this invocation.
65    pub capability_id: String,
66    /// `server_id` of the tool server being called.
67    pub tool_server: String,
68    /// Name of the tool being invoked.
69    pub tool_name: String,
70    /// SHA-256 hex of the serialized tool arguments (action binding).
71    pub action_hash: String,
72    /// Caller-chosen random string; must be unique within the TTL window.
73    pub nonce: String,
74    /// Unix seconds when this proof was created.
75    pub issued_at: u64,
76    /// Hex-encoded Ed25519 public key of the signer (must equal capability.subject).
77    pub agent_key: PublicKey,
78}
79
80// ---------------------------------------------------------------------------
81// DpopProof
82// ---------------------------------------------------------------------------
83
84/// A signed DPoP proof ready for transmission.
85///
86/// The `signature` covers the canonical JSON of `body`.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct DpopProof {
89    /// The proof body that was signed.
90    pub body: DpopProofBody,
91    /// Ed25519 signature over `canonical_json_bytes(&body)`.
92    pub signature: Signature,
93}
94
95impl DpopProof {
96    /// Sign a proof body with the agent's Ed25519 keypair.
97    ///
98    /// The `keypair` must be the one corresponding to `body.agent_key`.
99    /// The signature covers the canonical JSON of the body.
100    pub fn sign(body: DpopProofBody, keypair: &Keypair) -> Result<DpopProof, KernelError> {
101        let body_bytes = canonical_json_bytes(&body).map_err(|e| {
102            KernelError::DpopVerificationFailed(format!("failed to serialize proof body: {e}"))
103        })?;
104        let signature = keypair.sign(&body_bytes);
105        Ok(DpopProof { body, signature })
106    }
107
108    /// Sign a proof body with an arbitrary [`SigningBackend`].
109    ///
110    /// The backend's public key must equal `body.agent_key`. Use this entry
111    /// point when the agent's signing identity is served by a FIPS backend
112    /// (P-256 / P-384) rather than a historical Ed25519 keypair.
113    pub fn sign_with_backend(
114        body: DpopProofBody,
115        backend: &dyn SigningBackend,
116    ) -> Result<DpopProof, KernelError> {
117        let (signature, _bytes) = sign_canonical_with_backend(backend, &body).map_err(|e| {
118            KernelError::DpopVerificationFailed(format!("failed to sign proof body: {e}"))
119        })?;
120        Ok(DpopProof { body, signature })
121    }
122}
123
124// ---------------------------------------------------------------------------
125// DpopConfig
126// ---------------------------------------------------------------------------
127
128/// Configuration for DPoP proof verification.
129#[derive(Debug, Clone)]
130pub struct DpopConfig {
131    /// How many seconds a proof is valid after `issued_at`. Default: 300.
132    pub proof_ttl_secs: u64,
133    /// How many seconds of future-dated clock skew to tolerate. Default: 30.
134    pub max_clock_skew_secs: u64,
135    /// Maximum number of entries in the nonce replay cache. Default: 8192.
136    pub nonce_store_capacity: usize,
137}
138
139impl Default for DpopConfig {
140    fn default() -> Self {
141        Self {
142            proof_ttl_secs: 300,
143            max_clock_skew_secs: 30,
144            nonce_store_capacity: 8192,
145        }
146    }
147}
148
149// ---------------------------------------------------------------------------
150// DpopNonceStore
151// ---------------------------------------------------------------------------
152
153/// In-memory LRU nonce replay store.
154///
155/// Keys are `(nonce, capability_id)` pairs. Values are the `Instant` when
156/// the nonce was first seen. A nonce is rejected if it is still within the
157/// TTL window when seen a second time.
158///
159/// This is intentionally synchronous (no async) and uses `std::sync::Mutex`
160/// so it integrates cleanly into the `Guard` pipeline.
161pub struct DpopNonceStore {
162    inner: Mutex<LruCache<(String, String), Instant>>,
163    ttl: Duration,
164}
165
166impl DpopNonceStore {
167    /// Create a new nonce store.
168    ///
169    /// `capacity` is the maximum number of (nonce, capability_id) pairs to
170    /// remember. `ttl` is how long a nonce is considered "live" after first
171    /// use. After the TTL elapses, the same nonce can be used again.
172    pub fn new(capacity: usize, ttl: Duration) -> Self {
173        // NonZeroUsize::new returns None for 0; fall back to 1024 in that case.
174        let nz = NonZeroUsize::new(capacity).unwrap_or_else(|| {
175            // SAFETY: 1024 is a compile-time constant greater than zero.
176            NonZeroUsize::new(1024).unwrap_or(NonZeroUsize::MIN)
177        });
178        Self {
179            inner: Mutex::new(LruCache::new(nz)),
180            ttl,
181        }
182    }
183
184    /// Check a nonce and insert it if not already live.
185    ///
186    /// Returns `Ok(true)` if the nonce is fresh (accepted).
187    /// Returns `Ok(false)` if the nonce was already used within the TTL window
188    /// (rejected -- replay detected).
189    /// Returns `Err` if the internal mutex is poisoned (fail-closed: deny).
190    pub fn check_and_insert(&self, nonce: &str, capability_id: &str) -> Result<bool, KernelError> {
191        let key = (nonce.to_string(), capability_id.to_string());
192        let mut cache = self.inner.lock().map_err(|_| {
193            error!("DPoP nonce store mutex is poisoned; denying proof as fail-closed");
194            KernelError::DpopVerificationFailed(
195                "nonce store mutex poisoned; cannot verify replay safety".to_string(),
196            )
197        })?;
198
199        if let Some(first_seen) = cache.peek(&key) {
200            if first_seen.elapsed() < self.ttl {
201                // Nonce is still live -- replay detected.
202                return Ok(false);
203            }
204            // TTL has elapsed; remove the stale entry so we can re-insert.
205            cache.pop(&key);
206        }
207
208        cache.put(key, Instant::now());
209        Ok(true)
210    }
211}
212
213// ---------------------------------------------------------------------------
214// verify_dpop_proof
215// ---------------------------------------------------------------------------
216
217/// Verify a DPoP proof against the given capability and invocation context.
218///
219/// All six verification steps must pass; the first failure returns an error.
220///
221/// # Arguments
222///
223/// * `proof` - the signed DPoP proof from the agent
224/// * `capability` - the capability token being used for this invocation
225/// * `expected_tool_server` - `server_id` the kernel expects
226/// * `expected_tool_name` - tool name the kernel expects
227/// * `expected_action_hash` - SHA-256 hex of the serialized tool arguments
228/// * `nonce_store` - shared replay-rejection store
229/// * `config` - TTL and clock-skew bounds
230pub fn verify_dpop_proof(
231    proof: &DpopProof,
232    capability: &CapabilityToken,
233    expected_tool_server: &str,
234    expected_tool_name: &str,
235    expected_action_hash: &str,
236    nonce_store: &DpopNonceStore,
237    config: &DpopConfig,
238) -> Result<(), KernelError> {
239    // Step 1: Schema check.
240    if !is_supported_dpop_schema(&proof.body.schema) {
241        return Err(KernelError::DpopVerificationFailed(format!(
242            "unknown DPoP schema: expected {DPOP_SCHEMA}, got {}",
243            proof.body.schema
244        )));
245    }
246
247    // Step 2: Sender constraint -- agent_key must equal capability.subject.
248    if proof.body.agent_key != capability.subject {
249        return Err(KernelError::DpopVerificationFailed(
250            "agent_key does not match capability subject (sender constraint violated)".to_string(),
251        ));
252    }
253
254    // Step 3: Binding fields.
255    if proof.body.capability_id != capability.id
256        || proof.body.tool_server != expected_tool_server
257        || proof.body.tool_name != expected_tool_name
258        || proof.body.action_hash != expected_action_hash
259    {
260        return Err(KernelError::DpopVerificationFailed(
261            "binding fields do not match: capability_id, tool_server, tool_name, or action_hash mismatch".to_string(),
262        ));
263    }
264
265    // Step 4: Freshness check.
266    let now_secs = SystemTime::now()
267        .duration_since(UNIX_EPOCH)
268        .map(|d| d.as_secs())
269        .unwrap_or(0);
270
271    // Proof must not be future-dated beyond clock skew tolerance: issued_at <= now + skew.
272    // Check this first so that an astronomically large issued_at (e.g. u64::MAX) is
273    // rejected here before the expiry arithmetic below can overflow.
274    if proof.body.issued_at > now_secs.saturating_add(config.max_clock_skew_secs) {
275        return Err(KernelError::DpopVerificationFailed(format!(
276            "proof issued_at={} is too far in the future (now={}, skew={})",
277            proof.body.issued_at, now_secs, config.max_clock_skew_secs
278        )));
279    }
280
281    // Proof must not be expired: issued_at + ttl >= now.
282    // Use saturating_add as a defence-in-depth measure; the future-dated check
283    // above ensures issued_at is near now, so saturation should never trigger
284    // in practice for well-formed proofs.
285    if proof.body.issued_at.saturating_add(config.proof_ttl_secs) < now_secs {
286        return Err(KernelError::DpopVerificationFailed(format!(
287            "proof expired: issued_at={} ttl={} now={}",
288            proof.body.issued_at, config.proof_ttl_secs, now_secs
289        )));
290    }
291
292    // Proof must not be too far in the past beyond TTL + clock skew.
293    // A valid proof satisfies: issued_at >= now - (proof_ttl_secs + max_clock_skew_secs).
294    // This guards against proofs with timestamps so old they predate any plausible clock skew.
295    let stale_threshold =
296        now_secs.saturating_sub(config.proof_ttl_secs + config.max_clock_skew_secs);
297    if proof.body.issued_at < stale_threshold {
298        return Err(KernelError::DpopVerificationFailed(format!(
299            "proof issued_at={} is too far in the past (now={}, ttl={}, skew={})",
300            proof.body.issued_at, now_secs, config.proof_ttl_secs, config.max_clock_skew_secs
301        )));
302    }
303
304    // Step 5: Signature verification.
305    let body_bytes = canonical_json_bytes(&proof.body).map_err(|e| {
306        KernelError::DpopVerificationFailed(format!("failed to serialize proof body: {e}"))
307    })?;
308    if !proof.body.agent_key.verify(&body_bytes, &proof.signature) {
309        return Err(KernelError::DpopVerificationFailed(
310            "proof signature verification failed".to_string(),
311        ));
312    }
313
314    // Step 6: Nonce replay check.
315    if !nonce_store.check_and_insert(&proof.body.nonce, &proof.body.capability_id)? {
316        return Err(KernelError::DpopVerificationFailed(
317            "nonce replayed: this nonce has already been used within the TTL window".to_string(),
318        ));
319    }
320
321    Ok(())
322}
323
324#[cfg(test)]
325#[allow(clippy::unwrap_used, clippy::expect_used)]
326mod backend_tests {
327    use super::*;
328    use chio_core::crypto::Ed25519Backend;
329
330    #[test]
331    fn ed25519_backend_produces_equivalent_dpop_proof() {
332        // Signing via `DpopProof::sign_with_backend(..., &Ed25519Backend)` must
333        // be verifier-equivalent to the historical `DpopProof::sign(..., &Keypair)`
334        // path. The stored `agent_key.verify(...)` pathway already dispatches
335        // on algorithm tag, so either signing entry point must produce a proof
336        // whose verification succeeds.
337        let kp = Keypair::generate();
338        let backend = Ed25519Backend::new(kp.clone());
339        let body = DpopProofBody {
340            schema: DPOP_SCHEMA.to_string(),
341            capability_id: "cap-1".to_string(),
342            tool_server: "srv".to_string(),
343            tool_name: "tool".to_string(),
344            action_hash: "hash".to_string(),
345            nonce: "nonce-1".to_string(),
346            issued_at: 1_000,
347            agent_key: kp.public_key(),
348        };
349        let proof = DpopProof::sign_with_backend(body.clone(), &backend).unwrap();
350        let bytes = canonical_json_bytes(&proof.body).unwrap();
351        assert!(proof.body.agent_key.verify(&bytes, &proof.signature));
352    }
353
354    // The P-256 / P-384 DPoP signing round-trip is exercised in
355    // `chio-core-types` where the `fips` feature is directly in scope
356    // (see `capability.rs` tests). The DPoP verifier path ultimately calls
357    // `PublicKey::verify`, so algorithm dispatch is fully covered there.
358}