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::token::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 direct 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 the stateless parts of a DPoP proof against an invocation context.
218///
219/// This validates schema, sender binding, invocation binding, freshness, and
220/// signature, but deliberately does not consult or mutate the replay nonce
221/// store. Use this only for preview paths that must not burn a nonce before the
222/// authoritative invocation. Runtime execution must call [`verify_dpop_proof`].
223///
224/// # Arguments
225///
226/// * `proof` - the signed DPoP proof from the agent
227/// * `capability` - the capability token being used for this invocation
228/// * `expected_tool_server` - `server_id` the kernel expects
229/// * `expected_tool_name` - tool name the kernel expects
230/// * `expected_action_hash` - SHA-256 hex of the serialized tool arguments
231/// * `config` - TTL and clock-skew bounds
232pub fn verify_dpop_proof_stateless(
233 proof: &DpopProof,
234 capability: &CapabilityToken,
235 expected_tool_server: &str,
236 expected_tool_name: &str,
237 expected_action_hash: &str,
238 config: &DpopConfig,
239) -> Result<(), KernelError> {
240 // Step 1: Schema check.
241 if !is_supported_dpop_schema(&proof.body.schema) {
242 return Err(KernelError::DpopVerificationFailed(format!(
243 "unknown DPoP schema: expected {DPOP_SCHEMA}, got {}",
244 proof.body.schema
245 )));
246 }
247
248 // Step 2: Sender constraint -- agent_key must equal capability.subject.
249 if proof.body.agent_key != capability.subject {
250 return Err(KernelError::DpopVerificationFailed(
251 "agent_key does not match capability subject (sender constraint violated)".to_string(),
252 ));
253 }
254
255 // Step 3: Binding fields.
256 if proof.body.capability_id != capability.id
257 || proof.body.tool_server != expected_tool_server
258 || proof.body.tool_name != expected_tool_name
259 || proof.body.action_hash != expected_action_hash
260 {
261 return Err(KernelError::DpopVerificationFailed(
262 "binding fields do not match: capability_id, tool_server, tool_name, or action_hash mismatch".to_string(),
263 ));
264 }
265
266 // Step 4: Freshness check.
267 let now_secs = SystemTime::now()
268 .duration_since(UNIX_EPOCH)
269 .map(|d| d.as_secs())
270 .unwrap_or(0);
271
272 // Proof must not be future-dated beyond clock skew tolerance: issued_at <= now + skew.
273 // Check this first so that an astronomically large issued_at (e.g. u64::MAX) is
274 // rejected here before the expiry arithmetic below can overflow.
275 if proof.body.issued_at > now_secs.saturating_add(config.max_clock_skew_secs) {
276 return Err(KernelError::DpopVerificationFailed(format!(
277 "proof issued_at={} is too far in the future (now={}, skew={})",
278 proof.body.issued_at, now_secs, config.max_clock_skew_secs
279 )));
280 }
281
282 // Proof must not be expired: issued_at + ttl >= now.
283 // Use saturating_add as a defence-in-depth measure; the future-dated check
284 // above ensures issued_at is near now, so saturation should never trigger
285 // in practice for well-formed proofs.
286 if proof.body.issued_at.saturating_add(config.proof_ttl_secs) < now_secs {
287 return Err(KernelError::DpopVerificationFailed(format!(
288 "proof expired: issued_at={} ttl={} now={}",
289 proof.body.issued_at, config.proof_ttl_secs, now_secs
290 )));
291 }
292
293 // Proof must not be too far in the past beyond TTL + clock skew.
294 // A valid proof satisfies: issued_at >= now - (proof_ttl_secs + max_clock_skew_secs).
295 // This guards against proofs with timestamps so old they predate any plausible clock skew.
296 let stale_threshold =
297 now_secs.saturating_sub(config.proof_ttl_secs + config.max_clock_skew_secs);
298 if proof.body.issued_at < stale_threshold {
299 return Err(KernelError::DpopVerificationFailed(format!(
300 "proof issued_at={} is too far in the past (now={}, ttl={}, skew={})",
301 proof.body.issued_at, now_secs, config.proof_ttl_secs, config.max_clock_skew_secs
302 )));
303 }
304
305 // Step 5: Signature verification.
306 let body_bytes = canonical_json_bytes(&proof.body).map_err(|e| {
307 KernelError::DpopVerificationFailed(format!("failed to serialize proof body: {e}"))
308 })?;
309 if !proof.body.agent_key.verify(&body_bytes, &proof.signature) {
310 return Err(KernelError::DpopVerificationFailed(
311 "proof signature verification failed".to_string(),
312 ));
313 }
314
315 Ok(())
316}
317
318/// Verify a DPoP proof against the given capability and invocation context.
319///
320/// All six verification steps must pass; the first failure returns an error.
321///
322/// # Arguments
323///
324/// * `proof` - the signed DPoP proof from the agent
325/// * `capability` - the capability token being used for this invocation
326/// * `expected_tool_server` - `server_id` the kernel expects
327/// * `expected_tool_name` - tool name the kernel expects
328/// * `expected_action_hash` - SHA-256 hex of the serialized tool arguments
329/// * `nonce_store` - shared replay-rejection store
330/// * `config` - TTL and clock-skew bounds
331pub fn verify_dpop_proof(
332 proof: &DpopProof,
333 capability: &CapabilityToken,
334 expected_tool_server: &str,
335 expected_tool_name: &str,
336 expected_action_hash: &str,
337 nonce_store: &DpopNonceStore,
338 config: &DpopConfig,
339) -> Result<(), KernelError> {
340 verify_dpop_proof_stateless(
341 proof,
342 capability,
343 expected_tool_server,
344 expected_tool_name,
345 expected_action_hash,
346 config,
347 )?;
348
349 // Step 6: Nonce replay check.
350 if !nonce_store.check_and_insert(&proof.body.nonce, &proof.body.capability_id)? {
351 return Err(KernelError::DpopVerificationFailed(
352 "nonce replayed: this nonce has already been used within the TTL window".to_string(),
353 ));
354 }
355
356 Ok(())
357}
358
359#[cfg(test)]
360#[allow(clippy::unwrap_used, clippy::expect_used)]
361mod backend_tests {
362 use super::*;
363 use chio_core::crypto::Ed25519Backend;
364
365 #[test]
366 fn ed25519_backend_produces_equivalent_dpop_proof() {
367 // Signing via `DpopProof::sign_with_backend(..., &Ed25519Backend)` must
368 // be verifier-equivalent to the `DpopProof::sign(..., &Keypair)`
369 // path. The stored `agent_key.verify(...)` pathway already dispatches
370 // on algorithm tag, so either signing entry point must produce a proof
371 // whose verification succeeds.
372 let kp = Keypair::generate();
373 let backend = Ed25519Backend::new(kp.clone());
374 let body = DpopProofBody {
375 schema: DPOP_SCHEMA.to_string(),
376 capability_id: "cap-1".to_string(),
377 tool_server: "srv".to_string(),
378 tool_name: "tool".to_string(),
379 action_hash: "hash".to_string(),
380 nonce: "nonce-1".to_string(),
381 issued_at: 1_000,
382 agent_key: kp.public_key(),
383 };
384 let proof = DpopProof::sign_with_backend(body.clone(), &backend).unwrap();
385 let bytes = canonical_json_bytes(&proof.body).unwrap();
386 assert!(proof.body.agent_key.verify(&bytes, &proof.signature));
387 }
388
389 // The P-256 / P-384 DPoP signing round-trip is exercised in
390 // `chio-core-types` where the `fips` feature is directly in scope
391 // (see `capability.rs` tests). The DPoP verifier path ultimately calls
392 // `PublicKey::verify`, so algorithm dispatch is fully covered there.
393}