1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct HttpReceipt {
24 pub id: String,
26
27 pub request_id: String,
29
30 pub route_pattern: String,
32
33 pub method: HttpMethod,
35
36 pub caller_identity_hash: String,
38
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub session_id: Option<String>,
42
43 pub verdict: Verdict,
45
46 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub evidence: Vec<GuardEvidence>,
49
50 pub response_status: u16,
58
59 pub timestamp: u64,
61
62 pub content_hash: String,
64
65 pub policy_hash: String,
67
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub capability_id: Option<String>,
71
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub metadata: Option<serde_json::Value>,
75
76 pub kernel_key: PublicKey,
78
79 pub signature: Signature,
81}
82
83#[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 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 #[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 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 #[must_use]
162 pub fn is_allowed(&self) -> bool {
163 self.verdict.is_allowed()
164 }
165
166 #[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 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 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 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}