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::{
8    kinds::BoundaryClass, kinds::ObservationOutcome, kinds::ReceiptKind, kinds::RedactionMode,
9    kinds::ToolOrigin, kinds::TrustLevel, metadata::ActorRef, metadata::GuardEvidence,
10};
11use chio_core_types::{canonical_json_bytes, sha256_hex};
12
13use crate::method::HttpMethod;
14use crate::verdict::Verdict;
15
16pub const CHIO_HTTP_STATUS_SCOPE_KEY: &str = "chio_http_status_scope";
17pub const CHIO_DECISION_RECEIPT_ID_KEY: &str = "chio_decision_receipt_id";
18pub const CHIO_KERNEL_RECEIPT_ID_KEY: &str = "chio_kernel_receipt_id";
19pub const CHIO_HTTP_STATUS_SCOPE_DECISION: &str = "decision";
20pub const CHIO_HTTP_STATUS_SCOPE_FINAL: &str = "final";
21
22/// Signed receipt for an HTTP request evaluation.
23/// Binds the request identity, route, method, verdict, and guard evidence
24/// under an Ed25519 signature from the kernel.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct HttpReceipt {
27    /// Authoritative content-addressed receipt id.
28    pub id: String,
29
30    /// Unique request ID this receipt covers.
31    pub request_id: String,
32
33    /// The matched route pattern (e.g., "/pets/{petId}").
34    pub route_pattern: String,
35
36    /// HTTP method of the evaluated request.
37    pub method: HttpMethod,
38
39    /// SHA-256 hash of the caller identity.
40    pub caller_identity_hash: String,
41
42    /// Session ID the request belonged to.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub session_id: Option<String>,
45
46    /// The kernel's verdict.
47    pub verdict: Verdict,
48
49    /// Signed semantic class for this HTTP receipt.
50    pub receipt_kind: ReceiptKind,
51
52    /// Signed boundary class for this HTTP receipt.
53    pub boundary_class: BoundaryClass,
54
55    /// Observation outcome. Mediated HTTP receipts must omit this field.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub observation_outcome: Option<ObservationOutcome>,
58
59    /// Where the protected effect executes relative to Chio.
60    pub tool_origin: ToolOrigin,
61
62    /// Redaction mode applied to signed receipt details.
63    pub redaction_mode: RedactionMode,
64
65    /// Actor chain associated with the HTTP decision.
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub actor_chain: Vec<ActorRef>,
68
69    /// Per-guard evidence collected during evaluation.
70    #[serde(default, skip_serializing_if = "Vec::is_empty")]
71    pub evidence: Vec<GuardEvidence>,
72
73    /// HTTP status Chio associated with the evaluation outcome at receipt-signing
74    /// time.
75    ///
76    /// For deny receipts this is the concrete error status Chio will emit.
77    /// For allow receipts produced before an upstream or inner response exists,
78    /// this is evaluation-time status metadata rather than guaranteed
79    /// downstream response evidence.
80    pub response_status: u16,
81
82    /// Unix timestamp (seconds) when the receipt was created.
83    pub timestamp: u64,
84
85    /// SHA-256 hash binding the request content to this receipt.
86    pub content_hash: String,
87
88    /// SHA-256 hash of the policy that was applied.
89    pub policy_hash: String,
90
91    /// Trust level of the signed decision.
92    pub trust_level: TrustLevel,
93
94    /// Capability ID that was exercised, if any.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub capability_id: Option<String>,
97
98    /// Optional metadata for extensibility.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub metadata: Option<serde_json::Value>,
101
102    /// The kernel's public key (for verification without out-of-band lookup).
103    pub kernel_key: PublicKey,
104
105    /// Ed25519 signature over canonical JSON of the body fields.
106    pub signature: Signature,
107}
108
109/// The body of an HTTP receipt (everything except the signature).
110/// Used for signing and verification.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct HttpReceiptBody {
113    pub id: String,
114    pub request_id: String,
115    pub route_pattern: String,
116    pub method: HttpMethod,
117    pub caller_identity_hash: String,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub session_id: Option<String>,
120    pub verdict: Verdict,
121    pub receipt_kind: ReceiptKind,
122    pub boundary_class: BoundaryClass,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub observation_outcome: Option<ObservationOutcome>,
125    pub tool_origin: ToolOrigin,
126    pub redaction_mode: RedactionMode,
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub actor_chain: Vec<ActorRef>,
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub evidence: Vec<GuardEvidence>,
131    pub response_status: u16,
132    pub timestamp: u64,
133    pub content_hash: String,
134    pub policy_hash: String,
135    pub trust_level: TrustLevel,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub capability_id: Option<String>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub metadata: Option<serde_json::Value>,
140    pub kernel_key: PublicKey,
141}
142
143impl HttpReceiptBody {
144    fn validate_authority_semantics(&self) -> chio_core_types::Result<()> {
145        if self.receipt_kind != ReceiptKind::MediatedDecision {
146            return Err(chio_core_types::Error::CanonicalJson(
147                "HTTP receipts must be mediated_decision receipts".to_string(),
148            ));
149        }
150        if self.boundary_class != BoundaryClass::Prevent {
151            return Err(chio_core_types::Error::CanonicalJson(
152                "HTTP receipts must use the prevent boundary class".to_string(),
153            ));
154        }
155        if self.observation_outcome.is_some() {
156            return Err(chio_core_types::Error::CanonicalJson(
157                "mediated HTTP receipts must not carry observation_outcome".to_string(),
158            ));
159        }
160        if self.trust_level != TrustLevel::Mediated {
161            return Err(chio_core_types::Error::CanonicalJson(
162                "HTTP receipts must use mediated trust level".to_string(),
163            ));
164        }
165        Ok(())
166    }
167}
168
169impl HttpReceipt {
170    /// Sign a receipt body with the kernel's keypair.
171    pub fn sign(mut body: HttpReceiptBody, keypair: &Keypair) -> chio_core_types::Result<Self> {
172        body.id = compute_http_receipt_id(&body)?;
173        body.validate_authority_semantics()?;
174        let (signature, _bytes) = keypair.sign_canonical(&body)?;
175        Ok(Self {
176            id: body.id,
177            request_id: body.request_id,
178            route_pattern: body.route_pattern,
179            method: body.method,
180            caller_identity_hash: body.caller_identity_hash,
181            session_id: body.session_id,
182            verdict: body.verdict,
183            receipt_kind: body.receipt_kind,
184            boundary_class: body.boundary_class,
185            observation_outcome: body.observation_outcome,
186            tool_origin: body.tool_origin,
187            redaction_mode: body.redaction_mode,
188            actor_chain: body.actor_chain,
189            evidence: body.evidence,
190            response_status: body.response_status,
191            timestamp: body.timestamp,
192            content_hash: body.content_hash,
193            policy_hash: body.policy_hash,
194            trust_level: body.trust_level,
195            capability_id: body.capability_id,
196            metadata: body.metadata,
197            kernel_key: body.kernel_key,
198            signature,
199        })
200    }
201
202    /// Extract the body for re-verification.
203    #[must_use]
204    pub fn body(&self) -> HttpReceiptBody {
205        HttpReceiptBody {
206            id: self.id.clone(),
207            request_id: self.request_id.clone(),
208            route_pattern: self.route_pattern.clone(),
209            method: self.method,
210            caller_identity_hash: self.caller_identity_hash.clone(),
211            session_id: self.session_id.clone(),
212            verdict: self.verdict.clone(),
213            receipt_kind: self.receipt_kind,
214            boundary_class: self.boundary_class,
215            observation_outcome: self.observation_outcome,
216            tool_origin: self.tool_origin,
217            redaction_mode: self.redaction_mode,
218            actor_chain: self.actor_chain.clone(),
219            evidence: self.evidence.clone(),
220            response_status: self.response_status,
221            timestamp: self.timestamp,
222            content_hash: self.content_hash.clone(),
223            policy_hash: self.policy_hash.clone(),
224            trust_level: self.trust_level,
225            capability_id: self.capability_id.clone(),
226            metadata: self.metadata.clone(),
227            kernel_key: self.kernel_key.clone(),
228        }
229    }
230
231    /// Verify the receipt signature against the embedded kernel key.
232    pub fn verify_signature(&self) -> chio_core_types::Result<bool> {
233        let body = self.body();
234        if body.validate_authority_semantics().is_err() {
235            return Ok(false);
236        }
237        if compute_http_receipt_id(&body)? != self.id {
238            return Ok(false);
239        }
240        self.kernel_key.verify_canonical(&body, &self.signature)
241    }
242
243    /// Recompute the content-addressed HTTP receipt id.
244    pub fn recompute_id(&self) -> chio_core_types::Result<String> {
245        compute_http_receipt_id(&self.body())
246    }
247
248    /// Whether this receipt id matches the canonical body fields.
249    pub fn receipt_id_valid(&self) -> chio_core_types::Result<bool> {
250        Ok(self.recompute_id()? == self.id)
251    }
252
253    /// Whether this receipt records an authoritative allow.
254    #[must_use]
255    pub fn is_allowed(&self) -> bool {
256        self.is_authorized()
257    }
258
259    /// Whether this receipt is an authoritative Chio authorization.
260    #[must_use]
261    pub fn is_authorized(&self) -> bool {
262        self.receipt_kind == ReceiptKind::MediatedDecision
263            && self.boundary_class == BoundaryClass::Prevent
264            && self.observation_outcome.is_none()
265            && self.trust_level == TrustLevel::Mediated
266            && self.verdict.is_allowed()
267    }
268
269    /// Whether this receipt records a deny verdict.
270    #[must_use]
271    pub fn is_denied(&self) -> bool {
272        self.verdict.is_denied()
273    }
274
275    fn chio_receipt_body(
276        &self,
277    ) -> chio_core_types::Result<chio_core_types::receipt::body::ChioReceiptBody> {
278        // A durable receipt store verifies `ToolCallAction::verify_hash()` before
279        // accepting a receipt, so `parameter_hash` must be the canonical hash of
280        // the action parameters, not the HTTP body content hash. Deriving it from
281        // the parameters keeps the converted receipt verifiable end to end; a
282        // store that recomputes the hash then accepts the record instead of
283        // rejecting it. The HTTP body content hash stays bound to the receipt
284        // through the `content_hash` field below.
285        let action = chio_core_types::receipt::decision::ToolCallAction::from_parameters(
286            serde_json::json!({
287                "method": self.method.to_string(),
288                "route": self.route_pattern,
289                "request_id": self.request_id,
290            }),
291        )?;
292
293        Ok(chio_core_types::receipt::body::ChioReceiptBody {
294            id: self.id.clone(),
295            timestamp: self.timestamp,
296            capability_id: self.capability_id.clone().unwrap_or_default(),
297            tool_server: "http".to_string(),
298            tool_name: format!("{} {}", self.method, self.route_pattern),
299            action,
300            decision: Some(self.verdict.to_decision()),
301            receipt_kind: self.receipt_kind,
302            boundary_class: self.boundary_class,
303            observation_outcome: self.observation_outcome,
304            tool_origin: self.tool_origin,
305            redaction_mode: self.redaction_mode,
306            actor_chain: self.actor_chain.clone(),
307            content_hash: self.content_hash.clone(),
308            policy_hash: self.policy_hash.clone(),
309            evidence: self.evidence.clone(),
310            metadata: self.metadata.clone(),
311            trust_level: self.trust_level,
312            tenant_id: None,
313            kernel_key: self.kernel_key.clone(),
314            bbs_projection_version: None,
315        })
316    }
317
318    /// Convert this HTTP receipt into a signed core ChioReceipt for unified storage.
319    pub fn to_chio_receipt_with_keypair(
320        &self,
321        keypair: &Keypair,
322    ) -> chio_core_types::Result<chio_core_types::receipt::body::ChioReceipt> {
323        let mut chio_body = self.chio_receipt_body()?;
324        let canonical = canonical_json_bytes(&chio_body)?;
325        chio_body.content_hash = sha256_hex(&canonical);
326        chio_core_types::receipt::body::ChioReceipt::sign(chio_body, keypair)
327    }
328
329    /// Convert this HTTP receipt into a core ChioReceipt for unified storage.
330    ///
331    /// This method fails closed because a valid ChioReceipt signature cannot be
332    /// derived from an HttpReceipt without the kernel signing keypair.
333    pub fn to_chio_receipt(
334        &self,
335    ) -> chio_core_types::Result<chio_core_types::receipt::body::ChioReceipt> {
336        Err(chio_core_types::Error::CanonicalJson(
337            "cannot convert HttpReceipt into signed ChioReceipt without the kernel keypair"
338                .to_string(),
339        ))
340    }
341}
342
343fn compute_http_receipt_id(body: &HttpReceiptBody) -> chio_core_types::Result<String> {
344    let mut value = serde_json::to_value(body)?;
345    let Some(object) = value.as_object_mut() else {
346        return Err(chio_core_types::Error::CanonicalJson(
347            "HTTP receipt body did not serialize to an object".to_string(),
348        ));
349    };
350    object.remove("id");
351    let canonical = canonical_json_bytes(&value)?;
352    Ok(sha256_hex(&canonical))
353}
354
355#[must_use]
356pub fn http_status_metadata_decision() -> Value {
357    let mut map = Map::new();
358    map.insert(
359        CHIO_HTTP_STATUS_SCOPE_KEY.to_string(),
360        Value::String(CHIO_HTTP_STATUS_SCOPE_DECISION.to_string()),
361    );
362    Value::Object(map)
363}
364
365#[must_use]
366pub fn http_status_metadata_final(decision_receipt_id: Option<&str>) -> Value {
367    let mut map = Map::new();
368    map.insert(
369        CHIO_HTTP_STATUS_SCOPE_KEY.to_string(),
370        Value::String(CHIO_HTTP_STATUS_SCOPE_FINAL.to_string()),
371    );
372    if let Some(id) = decision_receipt_id {
373        map.insert(
374            CHIO_DECISION_RECEIPT_ID_KEY.to_string(),
375            Value::String(id.to_string()),
376        );
377    }
378    Value::Object(map)
379}
380
381#[must_use]
382pub fn http_status_scope(metadata: Option<&Value>) -> Option<&str> {
383    metadata
384        .and_then(Value::as_object)
385        .and_then(|object| object.get(CHIO_HTTP_STATUS_SCOPE_KEY))
386        .and_then(Value::as_str)
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::verdict::Verdict;
393
394    use chio_test_support::prelude::*;
395
396    fn test_keypair() -> Keypair {
397        Keypair::generate()
398    }
399
400    fn sample_body(keypair: &Keypair) -> HttpReceiptBody {
401        HttpReceiptBody {
402            id: "receipt-001".to_string(),
403            request_id: "req-001".to_string(),
404            route_pattern: "/pets/{petId}".to_string(),
405            method: HttpMethod::Get,
406            caller_identity_hash: "abc123".to_string(),
407            session_id: Some("sess-001".to_string()),
408            verdict: Verdict::Allow,
409            receipt_kind: ReceiptKind::MediatedDecision,
410            boundary_class: BoundaryClass::Prevent,
411            observation_outcome: None,
412            tool_origin: ToolOrigin::CallerExecuted,
413            redaction_mode: RedactionMode::None,
414            actor_chain: Vec::new(),
415            evidence: vec![],
416            response_status: 200,
417            timestamp: 1700000000,
418            content_hash: "deadbeef".to_string(),
419            policy_hash: "cafebabe".to_string(),
420            trust_level: TrustLevel::Mediated,
421            capability_id: None,
422            metadata: None,
423            kernel_key: keypair.public_key(),
424        }
425    }
426
427    #[test]
428    fn sign_and_verify() {
429        let kp = test_keypair();
430        let body = sample_body(&kp);
431        let receipt = HttpReceipt::sign(body, &kp).test_unwrap();
432        assert!(receipt.verify_signature().test_unwrap());
433        assert!(receipt.is_allowed());
434        assert!(!receipt.is_denied());
435    }
436
437    #[test]
438    fn deny_receipt() {
439        let kp = test_keypair();
440        let mut body = sample_body(&kp);
441        body.verdict = Verdict::deny("no capability", "CapabilityGuard");
442        body.response_status = 403;
443        let receipt = HttpReceipt::sign(body, &kp).test_unwrap();
444        assert!(receipt.is_denied());
445        assert!(receipt.verify_signature().test_unwrap());
446    }
447
448    #[test]
449    fn sign_rejects_unsigned_authority_shape_drift() {
450        let kp = test_keypair();
451        let mut body = sample_body(&kp);
452        body.receipt_kind = ReceiptKind::TraceObservation;
453        body.boundary_class = BoundaryClass::DetectOnly;
454        body.observation_outcome = Some(ObservationOutcome::Observed);
455        body.trust_level = TrustLevel::Verified;
456
457        let error = HttpReceipt::sign(body, &kp).test_unwrap_err();
458        assert!(error
459            .to_string()
460            .contains("HTTP receipts must be mediated_decision receipts"));
461    }
462
463    #[test]
464    fn verify_signature_rejects_signed_authority_shape_drift() {
465        let kp = test_keypair();
466        let body = sample_body(&kp);
467        let mut receipt = HttpReceipt::sign(body, &kp).test_unwrap();
468        receipt.receipt_kind = ReceiptKind::TraceObservation;
469        receipt.boundary_class = BoundaryClass::DetectOnly;
470        receipt.observation_outcome = Some(ObservationOutcome::Observed);
471        receipt.trust_level = TrustLevel::Verified;
472        let (signature, _) = kp.sign_canonical(&receipt.body()).test_unwrap();
473        receipt.signature = signature;
474
475        assert!(!receipt.verify_signature().test_unwrap());
476    }
477
478    #[test]
479    fn body_roundtrip() {
480        let kp = test_keypair();
481        let body = sample_body(&kp);
482        let receipt = HttpReceipt::sign(body.clone(), &kp).test_unwrap();
483        let extracted = receipt.body();
484        // sign() overwrites body.id with the content-addressed id, so the
485        // extracted body carries the signed id rather than the caller-supplied
486        // value.
487        assert_eq!(extracted.id, receipt.id);
488        assert_ne!(extracted.id, body.id);
489        assert_eq!(extracted.route_pattern, body.route_pattern);
490    }
491
492    #[test]
493    fn serde_roundtrip() {
494        let kp = test_keypair();
495        let body = sample_body(&kp);
496        let receipt = HttpReceipt::sign(body, &kp).test_unwrap();
497        let json = serde_json::to_string(&receipt).test_unwrap();
498        let back: HttpReceipt = serde_json::from_str(&json).test_unwrap();
499        assert!(back.verify_signature().test_unwrap());
500    }
501
502    #[test]
503    fn to_chio_receipt_conversion() {
504        let kp = test_keypair();
505        let body = sample_body(&kp);
506        let receipt = HttpReceipt::sign(body, &kp).test_unwrap();
507        let error = receipt.to_chio_receipt().test_unwrap_err();
508        assert!(error
509            .to_string()
510            .contains("cannot convert HttpReceipt into signed ChioReceipt"));
511    }
512
513    #[test]
514    fn receipt_with_evidence_entries() {
515        let kp = test_keypair();
516        let mut body = sample_body(&kp);
517        body.evidence = vec![
518            GuardEvidence {
519                guard_name: "PolicyGuard".to_string(),
520                verdict: true,
521                details: Some("session-scoped allow".to_string()),
522            },
523            GuardEvidence {
524                guard_name: "RateLimitGuard".to_string(),
525                verdict: true,
526                details: None,
527            },
528        ];
529        let receipt = HttpReceipt::sign(body, &kp).test_unwrap();
530        assert!(receipt.verify_signature().test_unwrap());
531        assert_eq!(receipt.evidence.len(), 2);
532        assert_eq!(receipt.evidence[0].guard_name, "PolicyGuard");
533        assert!(receipt.evidence[0].verdict);
534    }
535
536    #[test]
537    fn receipt_with_metadata() {
538        let kp = test_keypair();
539        let mut body = sample_body(&kp);
540        body.metadata = Some(serde_json::json!({
541            "trace_id": "abc123",
542            "tags": ["production", "v2"]
543        }));
544        let receipt = HttpReceipt::sign(body, &kp).test_unwrap();
545        assert!(receipt.verify_signature().test_unwrap());
546        let meta = receipt.metadata.as_ref().test_unwrap();
547        assert_eq!(meta["trace_id"], "abc123");
548    }
549
550    #[test]
551    fn receipt_with_capability_id() {
552        let kp = test_keypair();
553        let mut body = sample_body(&kp);
554        body.capability_id = Some("cap-xyz-789".to_string());
555        let receipt = HttpReceipt::sign(body, &kp).test_unwrap();
556        assert!(receipt.verify_signature().test_unwrap());
557        assert_eq!(receipt.capability_id.as_deref(), Some("cap-xyz-789"));
558        let chio_receipt = receipt.to_chio_receipt_with_keypair(&kp).test_unwrap();
559        assert_eq!(chio_receipt.capability_id, "cap-xyz-789");
560        assert!(chio_receipt.verify_signature().test_unwrap());
561    }
562
563    #[test]
564    fn converted_receipt_has_a_verifiable_action_hash() {
565        // A durable receipt store recomputes the action parameter hash and
566        // rejects a receipt whose hash does not match its parameters. Signing
567        // alone is not enough: a receipt can carry a valid signature yet still be
568        // refused for a mismatched action hash, which would fail every durable
569        // append closed. The converted core receipt must therefore verify both.
570        let kp = test_keypair();
571        let body = sample_body(&kp);
572        let receipt = HttpReceipt::sign(body, &kp).test_unwrap();
573        let chio_receipt = receipt.to_chio_receipt_with_keypair(&kp).test_unwrap();
574        assert!(
575            chio_receipt.verify_signature().test_unwrap(),
576            "converted receipt signature must verify"
577        );
578        assert!(
579            chio_receipt.action.verify_hash().test_unwrap(),
580            "converted receipt action hash must match its parameters so a durable store accepts it"
581        );
582    }
583
584    #[test]
585    fn tampered_receipt_fails_verification() {
586        let kp = test_keypair();
587        let body = sample_body(&kp);
588        let mut receipt = HttpReceipt::sign(body, &kp).test_unwrap();
589        // Tamper with the response status
590        receipt.response_status = 500;
591        assert!(!receipt.verify_signature().test_unwrap());
592    }
593
594    #[test]
595    fn receipt_metadata_scope_roundtrip_and_signature_verifies() {
596        let kp = test_keypair();
597        let mut body = sample_body(&kp);
598        body.metadata = Some(http_status_metadata_final(Some("decision-001")));
599
600        let receipt = HttpReceipt::sign(body, &kp).test_unwrap();
601        assert_eq!(
602            http_status_scope(receipt.metadata.as_ref()),
603            Some(CHIO_HTTP_STATUS_SCOPE_FINAL)
604        );
605        assert!(receipt.verify_signature().test_unwrap());
606    }
607
608    #[test]
609    fn tampering_with_status_scope_metadata_breaks_signature() {
610        let kp = test_keypair();
611        let mut body = sample_body(&kp);
612        body.metadata = Some(http_status_metadata_decision());
613
614        let mut receipt = HttpReceipt::sign(body, &kp).test_unwrap();
615        receipt.metadata = Some(http_status_metadata_final(Some("decision-001")));
616
617        assert!(!receipt.verify_signature().test_unwrap());
618    }
619}