1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct HttpReceipt {
27 pub id: String,
29
30 pub request_id: String,
32
33 pub route_pattern: String,
35
36 pub method: HttpMethod,
38
39 pub caller_identity_hash: String,
41
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub session_id: Option<String>,
45
46 pub verdict: Verdict,
48
49 pub receipt_kind: ReceiptKind,
51
52 pub boundary_class: BoundaryClass,
54
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub observation_outcome: Option<ObservationOutcome>,
58
59 pub tool_origin: ToolOrigin,
61
62 pub redaction_mode: RedactionMode,
64
65 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 pub actor_chain: Vec<ActorRef>,
68
69 #[serde(default, skip_serializing_if = "Vec::is_empty")]
71 pub evidence: Vec<GuardEvidence>,
72
73 pub response_status: u16,
81
82 pub timestamp: u64,
84
85 pub content_hash: String,
87
88 pub policy_hash: String,
90
91 pub trust_level: TrustLevel,
93
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub capability_id: Option<String>,
97
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub metadata: Option<serde_json::Value>,
101
102 pub kernel_key: PublicKey,
104
105 pub signature: Signature,
107}
108
109#[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 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 #[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 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 pub fn recompute_id(&self) -> chio_core_types::Result<String> {
245 compute_http_receipt_id(&self.body())
246 }
247
248 pub fn receipt_id_valid(&self) -> chio_core_types::Result<bool> {
250 Ok(self.recompute_id()? == self.id)
251 }
252
253 #[must_use]
255 pub fn is_allowed(&self) -> bool {
256 self.is_authorized()
257 }
258
259 #[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 #[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 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 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 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 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 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 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}