Skip to main content

chio_http_core/
receipt.rs

1//! HTTP receipt: signed proof that an HTTP request was evaluated by Chio.
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Map, Value};
5
6use chio_core_types::crypto::{Keypair, PublicKey, Signature};
7use chio_core_types::receipt::GuardEvidence;
8use chio_core_types::{canonical_json_bytes, sha256_hex};
9
10use crate::method::HttpMethod;
11use crate::verdict::Verdict;
12
13pub const CHIO_HTTP_STATUS_SCOPE_KEY: &str = "chio_http_status_scope";
14pub const CHIO_DECISION_RECEIPT_ID_KEY: &str = "chio_decision_receipt_id";
15pub const CHIO_KERNEL_RECEIPT_ID_KEY: &str = "chio_kernel_receipt_id";
16pub const CHIO_HTTP_STATUS_SCOPE_DECISION: &str = "decision";
17pub const CHIO_HTTP_STATUS_SCOPE_FINAL: &str = "final";
18
19/// Signed receipt for an HTTP request evaluation.
20/// Binds the request identity, route, method, verdict, and guard evidence
21/// under an Ed25519 signature from the kernel.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct HttpReceipt {
24    /// Unique receipt ID (UUIDv7 recommended).
25    pub id: String,
26
27    /// Unique request ID this receipt covers.
28    pub request_id: String,
29
30    /// The matched route pattern (e.g., "/pets/{petId}").
31    pub route_pattern: String,
32
33    /// HTTP method of the evaluated request.
34    pub method: HttpMethod,
35
36    /// SHA-256 hash of the caller identity.
37    pub caller_identity_hash: String,
38
39    /// Session ID the request belonged to.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub session_id: Option<String>,
42
43    /// The kernel's verdict.
44    pub verdict: Verdict,
45
46    /// Per-guard evidence collected during evaluation.
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub evidence: Vec<GuardEvidence>,
49
50    /// HTTP status Chio associated with the evaluation outcome at receipt-signing
51    /// time.
52    ///
53    /// For deny receipts this is the concrete error status Chio will emit.
54    /// For allow receipts produced before an upstream or inner response exists,
55    /// this is evaluation-time status metadata rather than guaranteed
56    /// downstream response evidence.
57    pub response_status: u16,
58
59    /// Unix timestamp (seconds) when the receipt was created.
60    pub timestamp: u64,
61
62    /// SHA-256 hash binding the request content to this receipt.
63    pub content_hash: String,
64
65    /// SHA-256 hash of the policy that was applied.
66    pub policy_hash: String,
67
68    /// Capability ID that was exercised, if any.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub capability_id: Option<String>,
71
72    /// Optional metadata for extensibility.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub metadata: Option<serde_json::Value>,
75
76    /// The kernel's public key (for verification without out-of-band lookup).
77    pub kernel_key: PublicKey,
78
79    /// Ed25519 signature over canonical JSON of the body fields.
80    pub signature: Signature,
81}
82
83/// The body of an HTTP receipt (everything except the signature).
84/// Used for signing and verification.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct HttpReceiptBody {
87    pub id: String,
88    pub request_id: String,
89    pub route_pattern: String,
90    pub method: HttpMethod,
91    pub caller_identity_hash: String,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub session_id: Option<String>,
94    pub verdict: Verdict,
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub evidence: Vec<GuardEvidence>,
97    pub response_status: u16,
98    pub timestamp: u64,
99    pub content_hash: String,
100    pub policy_hash: String,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub capability_id: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub metadata: Option<serde_json::Value>,
105    pub kernel_key: PublicKey,
106}
107
108impl HttpReceipt {
109    /// Sign a receipt body with the kernel's keypair.
110    pub fn sign(body: HttpReceiptBody, keypair: &Keypair) -> chio_core_types::Result<Self> {
111        let (signature, _bytes) = keypair.sign_canonical(&body)?;
112        Ok(Self {
113            id: body.id,
114            request_id: body.request_id,
115            route_pattern: body.route_pattern,
116            method: body.method,
117            caller_identity_hash: body.caller_identity_hash,
118            session_id: body.session_id,
119            verdict: body.verdict,
120            evidence: body.evidence,
121            response_status: body.response_status,
122            timestamp: body.timestamp,
123            content_hash: body.content_hash,
124            policy_hash: body.policy_hash,
125            capability_id: body.capability_id,
126            metadata: body.metadata,
127            kernel_key: body.kernel_key,
128            signature,
129        })
130    }
131
132    /// Extract the body for re-verification.
133    #[must_use]
134    pub fn body(&self) -> HttpReceiptBody {
135        HttpReceiptBody {
136            id: self.id.clone(),
137            request_id: self.request_id.clone(),
138            route_pattern: self.route_pattern.clone(),
139            method: self.method,
140            caller_identity_hash: self.caller_identity_hash.clone(),
141            session_id: self.session_id.clone(),
142            verdict: self.verdict.clone(),
143            evidence: self.evidence.clone(),
144            response_status: self.response_status,
145            timestamp: self.timestamp,
146            content_hash: self.content_hash.clone(),
147            policy_hash: self.policy_hash.clone(),
148            capability_id: self.capability_id.clone(),
149            metadata: self.metadata.clone(),
150            kernel_key: self.kernel_key.clone(),
151        }
152    }
153
154    /// Verify the receipt signature against the embedded kernel key.
155    pub fn verify_signature(&self) -> chio_core_types::Result<bool> {
156        let body = self.body();
157        self.kernel_key.verify_canonical(&body, &self.signature)
158    }
159
160    /// Whether this receipt records an allow verdict.
161    #[must_use]
162    pub fn is_allowed(&self) -> bool {
163        self.verdict.is_allowed()
164    }
165
166    /// Whether this receipt records a deny verdict.
167    #[must_use]
168    pub fn is_denied(&self) -> bool {
169        self.verdict.is_denied()
170    }
171
172    fn chio_receipt_body(&self) -> chio_core_types::ChioReceiptBody {
173        let action = chio_core_types::ToolCallAction {
174            parameters: serde_json::json!({
175                "method": self.method.to_string(),
176                "route": self.route_pattern,
177                "request_id": self.request_id,
178            }),
179            parameter_hash: self.content_hash.clone(),
180        };
181
182        chio_core_types::ChioReceiptBody {
183            id: self.id.clone(),
184            timestamp: self.timestamp,
185            capability_id: self.capability_id.clone().unwrap_or_default(),
186            tool_server: "http".to_string(),
187            tool_name: format!("{} {}", self.method, self.route_pattern),
188            action,
189            decision: self.verdict.to_decision(),
190            content_hash: self.content_hash.clone(),
191            policy_hash: self.policy_hash.clone(),
192            evidence: self.evidence.clone(),
193            metadata: self.metadata.clone(),
194            trust_level: chio_core_types::receipt::TrustLevel::default(),
195            tenant_id: None,
196            kernel_key: self.kernel_key.clone(),
197        }
198    }
199
200    /// Convert this HTTP receipt into a signed core ChioReceipt for unified storage.
201    pub fn to_chio_receipt_with_keypair(
202        &self,
203        keypair: &Keypair,
204    ) -> chio_core_types::Result<chio_core_types::ChioReceipt> {
205        let mut chio_body = self.chio_receipt_body();
206        let canonical = canonical_json_bytes(&chio_body)?;
207        chio_body.content_hash = sha256_hex(&canonical);
208        chio_core_types::ChioReceipt::sign(chio_body, keypair)
209    }
210
211    /// Convert this HTTP receipt into a core ChioReceipt for unified storage.
212    ///
213    /// This method fails closed because a valid ChioReceipt signature cannot be
214    /// derived from an HttpReceipt without the kernel signing keypair.
215    pub fn to_chio_receipt(&self) -> chio_core_types::Result<chio_core_types::ChioReceipt> {
216        Err(chio_core_types::Error::CanonicalJson(
217            "cannot convert HttpReceipt into signed ChioReceipt without the kernel keypair"
218                .to_string(),
219        ))
220    }
221}
222
223#[must_use]
224pub fn http_status_metadata_decision() -> Value {
225    let mut map = Map::new();
226    map.insert(
227        CHIO_HTTP_STATUS_SCOPE_KEY.to_string(),
228        Value::String(CHIO_HTTP_STATUS_SCOPE_DECISION.to_string()),
229    );
230    Value::Object(map)
231}
232
233#[must_use]
234pub fn http_status_metadata_final(decision_receipt_id: Option<&str>) -> Value {
235    let mut map = Map::new();
236    map.insert(
237        CHIO_HTTP_STATUS_SCOPE_KEY.to_string(),
238        Value::String(CHIO_HTTP_STATUS_SCOPE_FINAL.to_string()),
239    );
240    if let Some(id) = decision_receipt_id {
241        map.insert(
242            CHIO_DECISION_RECEIPT_ID_KEY.to_string(),
243            Value::String(id.to_string()),
244        );
245    }
246    Value::Object(map)
247}
248
249#[must_use]
250pub fn http_status_scope(metadata: Option<&Value>) -> Option<&str> {
251    metadata
252        .and_then(Value::as_object)
253        .and_then(|object| object.get(CHIO_HTTP_STATUS_SCOPE_KEY))
254        .and_then(Value::as_str)
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::verdict::Verdict;
261
262    fn test_keypair() -> Keypair {
263        Keypair::generate()
264    }
265
266    fn sample_body(keypair: &Keypair) -> HttpReceiptBody {
267        HttpReceiptBody {
268            id: "receipt-001".to_string(),
269            request_id: "req-001".to_string(),
270            route_pattern: "/pets/{petId}".to_string(),
271            method: HttpMethod::Get,
272            caller_identity_hash: "abc123".to_string(),
273            session_id: Some("sess-001".to_string()),
274            verdict: Verdict::Allow,
275            evidence: vec![],
276            response_status: 200,
277            timestamp: 1700000000,
278            content_hash: "deadbeef".to_string(),
279            policy_hash: "cafebabe".to_string(),
280            capability_id: None,
281            metadata: None,
282            kernel_key: keypair.public_key(),
283        }
284    }
285
286    #[test]
287    fn sign_and_verify() {
288        let kp = test_keypair();
289        let body = sample_body(&kp);
290        let receipt = HttpReceipt::sign(body, &kp).unwrap();
291        assert!(receipt.verify_signature().unwrap());
292        assert!(receipt.is_allowed());
293        assert!(!receipt.is_denied());
294    }
295
296    #[test]
297    fn deny_receipt() {
298        let kp = test_keypair();
299        let mut body = sample_body(&kp);
300        body.verdict = Verdict::deny("no capability", "CapabilityGuard");
301        body.response_status = 403;
302        let receipt = HttpReceipt::sign(body, &kp).unwrap();
303        assert!(receipt.is_denied());
304        assert!(receipt.verify_signature().unwrap());
305    }
306
307    #[test]
308    fn body_roundtrip() {
309        let kp = test_keypair();
310        let body = sample_body(&kp);
311        let receipt = HttpReceipt::sign(body.clone(), &kp).unwrap();
312        let extracted = receipt.body();
313        assert_eq!(extracted.id, body.id);
314        assert_eq!(extracted.route_pattern, body.route_pattern);
315    }
316
317    #[test]
318    fn serde_roundtrip() {
319        let kp = test_keypair();
320        let body = sample_body(&kp);
321        let receipt = HttpReceipt::sign(body, &kp).unwrap();
322        let json = serde_json::to_string(&receipt).unwrap();
323        let back: HttpReceipt = serde_json::from_str(&json).unwrap();
324        assert!(back.verify_signature().unwrap());
325    }
326
327    #[test]
328    fn to_chio_receipt_conversion() {
329        let kp = test_keypair();
330        let body = sample_body(&kp);
331        let receipt = HttpReceipt::sign(body, &kp).unwrap();
332        let error = receipt.to_chio_receipt().unwrap_err();
333        assert!(error
334            .to_string()
335            .contains("cannot convert HttpReceipt into signed ChioReceipt"));
336    }
337
338    #[test]
339    fn receipt_with_evidence_entries() {
340        let kp = test_keypair();
341        let mut body = sample_body(&kp);
342        body.evidence = vec![
343            GuardEvidence {
344                guard_name: "PolicyGuard".to_string(),
345                verdict: true,
346                details: Some("session-scoped allow".to_string()),
347            },
348            GuardEvidence {
349                guard_name: "RateLimitGuard".to_string(),
350                verdict: true,
351                details: None,
352            },
353        ];
354        let receipt = HttpReceipt::sign(body, &kp).unwrap();
355        assert!(receipt.verify_signature().unwrap());
356        assert_eq!(receipt.evidence.len(), 2);
357        assert_eq!(receipt.evidence[0].guard_name, "PolicyGuard");
358        assert!(receipt.evidence[0].verdict);
359    }
360
361    #[test]
362    fn receipt_with_metadata() {
363        let kp = test_keypair();
364        let mut body = sample_body(&kp);
365        body.metadata = Some(serde_json::json!({
366            "trace_id": "abc123",
367            "tags": ["production", "v2"]
368        }));
369        let receipt = HttpReceipt::sign(body, &kp).unwrap();
370        assert!(receipt.verify_signature().unwrap());
371        let meta = receipt.metadata.as_ref().unwrap();
372        assert_eq!(meta["trace_id"], "abc123");
373    }
374
375    #[test]
376    fn receipt_with_capability_id() {
377        let kp = test_keypair();
378        let mut body = sample_body(&kp);
379        body.capability_id = Some("cap-xyz-789".to_string());
380        let receipt = HttpReceipt::sign(body, &kp).unwrap();
381        assert!(receipt.verify_signature().unwrap());
382        assert_eq!(receipt.capability_id.as_deref(), Some("cap-xyz-789"));
383        let chio_receipt = receipt.to_chio_receipt_with_keypair(&kp).unwrap();
384        assert_eq!(chio_receipt.capability_id, "cap-xyz-789");
385        assert!(chio_receipt.verify_signature().unwrap());
386    }
387
388    #[test]
389    fn tampered_receipt_fails_verification() {
390        let kp = test_keypair();
391        let body = sample_body(&kp);
392        let mut receipt = HttpReceipt::sign(body, &kp).unwrap();
393        // Tamper with the response status
394        receipt.response_status = 500;
395        assert!(!receipt.verify_signature().unwrap());
396    }
397
398    #[test]
399    fn receipt_metadata_scope_roundtrip_and_signature_verifies() {
400        let kp = test_keypair();
401        let mut body = sample_body(&kp);
402        body.metadata = Some(http_status_metadata_final(Some("decision-001")));
403
404        let receipt = HttpReceipt::sign(body, &kp).unwrap();
405        assert_eq!(
406            http_status_scope(receipt.metadata.as_ref()),
407            Some(CHIO_HTTP_STATUS_SCOPE_FINAL)
408        );
409        assert!(receipt.verify_signature().unwrap());
410    }
411
412    #[test]
413    fn tampering_with_status_scope_metadata_breaks_signature() {
414        let kp = test_keypair();
415        let mut body = sample_body(&kp);
416        body.metadata = Some(http_status_metadata_decision());
417
418        let mut receipt = HttpReceipt::sign(body, &kp).unwrap();
419        receipt.metadata = Some(http_status_metadata_final(Some("decision-001")));
420
421        assert!(!receipt.verify_signature().unwrap());
422    }
423}