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}