1use serde::{Deserialize, Serialize};
4use serde_json::Value as JsonValue;
5
6use crate::error::{Error, Result};
7use crate::hashing::{keccak256, sha256, Hash};
8use crate::signing::{verify_signature, Keypair, PublicKey, Signature, Signer};
9
10pub const RECEIPT_SCHEMA_VERSION: &str = "1.0.0";
15
16pub fn validate_receipt_version(version: &str) -> Result<()> {
18 if parse_semver_strict(version).is_none() {
19 return Err(Error::InvalidReceiptVersion {
20 version: version.to_string(),
21 });
22 }
23
24 if version != RECEIPT_SCHEMA_VERSION {
25 return Err(Error::UnsupportedReceiptVersion {
26 found: version.to_string(),
27 supported: RECEIPT_SCHEMA_VERSION.to_string(),
28 });
29 }
30
31 Ok(())
32}
33
34fn parse_semver_strict(version: &str) -> Option<(u64, u64, u64)> {
35 let mut parts = version.split('.');
36 let major = parse_semver_part(parts.next()?)?;
37 let minor = parse_semver_part(parts.next()?)?;
38 let patch = parse_semver_part(parts.next()?)?;
39 if parts.next().is_some() {
40 return None;
41 }
42
43 Some((major, minor, patch))
44}
45
46fn parse_semver_part(part: &str) -> Option<u64> {
47 if part.is_empty() {
48 return None;
49 }
50 if part.len() > 1 && part.starts_with('0') {
51 return None;
52 }
53 if !part.bytes().all(|b| b.is_ascii_digit()) {
54 return None;
55 }
56 part.parse().ok()
57}
58
59#[derive(Clone, Debug, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct Verdict {
63 pub passed: bool,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub gate_id: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub scores: Option<JsonValue>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub threshold: Option<f64>,
74}
75
76impl Verdict {
77 pub fn pass() -> Self {
79 Self {
80 passed: true,
81 gate_id: None,
82 scores: None,
83 threshold: None,
84 }
85 }
86
87 pub fn fail() -> Self {
89 Self {
90 passed: false,
91 gate_id: None,
92 scores: None,
93 threshold: None,
94 }
95 }
96
97 pub fn pass_with_gate(gate_id: impl Into<String>) -> Self {
99 Self {
100 passed: true,
101 gate_id: Some(gate_id.into()),
102 scores: None,
103 threshold: None,
104 }
105 }
106
107 pub fn fail_with_gate(gate_id: impl Into<String>) -> Self {
109 Self {
110 passed: false,
111 gate_id: Some(gate_id.into()),
112 scores: None,
113 threshold: None,
114 }
115 }
116}
117
118#[derive(Clone, Debug, Serialize, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct ViolationRef {
122 pub guard: String,
124 pub severity: String,
126 pub message: String,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub action: Option<String>,
131}
132
133#[derive(Clone, Debug, Default, Serialize, Deserialize)]
135#[serde(deny_unknown_fields)]
136pub struct Provenance {
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub clawdstrike_version: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub provider: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub policy_hash: Option<Hash>,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub ruleset: Option<String>,
149 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 pub violations: Vec<ViolationRef>,
152}
153
154#[derive(Clone, Debug, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct Receipt {
158 pub version: String,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub receipt_id: Option<String>,
163 pub timestamp: String,
165 pub content_hash: Hash,
167 pub verdict: Verdict,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub provenance: Option<Provenance>,
172 #[serde(skip_serializing_if = "Option::is_none")]
174 pub metadata: Option<JsonValue>,
175}
176
177impl Receipt {
178 pub fn new(content_hash: Hash, verdict: Verdict) -> Self {
180 Self {
181 version: RECEIPT_SCHEMA_VERSION.to_string(),
182 receipt_id: None,
183 timestamp: chrono::Utc::now().to_rfc3339(),
184 content_hash,
185 verdict,
186 provenance: None,
187 metadata: None,
188 }
189 }
190
191 pub fn with_id(mut self, id: impl Into<String>) -> Self {
193 self.receipt_id = Some(id.into());
194 self
195 }
196
197 pub fn with_provenance(mut self, provenance: Provenance) -> Self {
199 self.provenance = Some(provenance);
200 self
201 }
202
203 pub fn with_metadata(mut self, metadata: JsonValue) -> Self {
205 self.metadata = Some(metadata);
206 self
207 }
208
209 pub fn merge_metadata(mut self, metadata: JsonValue) -> Self {
214 if let Some(existing) = self.metadata.as_mut() {
215 merge_json_values(existing, metadata);
216 } else {
217 self.metadata = Some(metadata);
218 }
219 self
220 }
221
222 pub fn validate_version(&self) -> Result<()> {
224 validate_receipt_version(&self.version)
225 }
226
227 pub fn to_canonical_json(&self) -> Result<String> {
229 self.validate_version()?;
230 let value = serde_json::to_value(self)?;
231 crate::canonical::canonicalize(&value)
232 }
233
234 pub fn hash_sha256(&self) -> Result<Hash> {
236 let canonical = self.to_canonical_json()?;
237 Ok(sha256(canonical.as_bytes()))
238 }
239
240 pub fn hash_keccak256(&self) -> Result<Hash> {
242 let canonical = self.to_canonical_json()?;
243 Ok(keccak256(canonical.as_bytes()))
244 }
245}
246
247fn merge_json_values(target: &mut JsonValue, source: JsonValue) {
248 let JsonValue::Object(source_obj) = source else {
249 *target = source;
250 return;
251 };
252
253 let JsonValue::Object(target_obj) = target else {
254 *target = JsonValue::Object(serde_json::Map::new());
255 merge_json_values(target, JsonValue::Object(source_obj));
256 return;
257 };
258
259 for (key, value) in source_obj {
260 match (target_obj.get_mut(&key), value) {
261 (Some(existing), JsonValue::Object(new_obj)) => {
262 if existing.is_object() {
263 merge_json_values(existing, JsonValue::Object(new_obj));
264 } else {
265 *existing = JsonValue::Object(new_obj);
266 }
267 }
268 (_, new_value) => {
269 target_obj.insert(key, new_value);
270 }
271 }
272 }
273}
274
275#[derive(Clone, Debug, Serialize, Deserialize)]
277#[serde(deny_unknown_fields)]
278pub struct Signatures {
279 pub signer: Signature,
281 #[serde(skip_serializing_if = "Option::is_none")]
283 pub cosigner: Option<Signature>,
284}
285
286#[derive(Clone, Debug, Serialize, Deserialize)]
288#[serde(deny_unknown_fields)]
289pub struct SignedReceipt {
290 pub receipt: Receipt,
292 pub signatures: Signatures,
294}
295
296impl SignedReceipt {
297 pub fn sign(receipt: Receipt, keypair: &Keypair) -> Result<Self> {
299 Self::sign_with(receipt, keypair)
300 }
301
302 pub fn sign_with(receipt: Receipt, signer: &dyn Signer) -> Result<Self> {
304 receipt.validate_version()?;
305 let canonical = receipt.to_canonical_json()?;
306 let sig = signer.sign(canonical.as_bytes())?;
307
308 Ok(Self {
309 receipt,
310 signatures: Signatures {
311 signer: sig,
312 cosigner: None,
313 },
314 })
315 }
316
317 pub fn add_cosigner(&mut self, keypair: &Keypair) -> Result<()> {
319 self.add_cosigner_with(keypair)
320 }
321
322 pub fn add_cosigner_with(&mut self, signer: &dyn Signer) -> Result<()> {
324 self.receipt.validate_version()?;
325 let canonical = self.receipt.to_canonical_json()?;
326 self.signatures.cosigner = Some(signer.sign(canonical.as_bytes())?);
327 Ok(())
328 }
329
330 pub fn verify(&self, public_keys: &PublicKeySet) -> VerificationResult {
332 fn fail_result(code: &str, message: String) -> VerificationResult {
333 VerificationResult {
334 valid: false,
335 signer_valid: false,
336 cosigner_valid: None,
337 errors: vec![message],
338 error_codes: vec![code.to_string()],
339 policy_subcode: None,
340 }
341 }
342
343 if let Err(e) = self.receipt.validate_version() {
344 let code = match e {
345 Error::InvalidReceiptVersion { .. } => "VFY_RECEIPT_VERSION_INVALID",
346 Error::UnsupportedReceiptVersion { .. } => "VFY_RECEIPT_VERSION_UNSUPPORTED",
347 _ => "VFY_INTERNAL_UNEXPECTED",
348 };
349 return fail_result(code, e.to_string());
350 }
351
352 let canonical = match self.receipt.to_canonical_json() {
353 Ok(c) => c,
354 Err(e) => {
355 return fail_result(
356 "VFY_INTERNAL_UNEXPECTED",
357 format!("Failed to serialize receipt: {}", e),
358 );
359 }
360 };
361 let message = canonical.as_bytes();
362
363 let mut result = VerificationResult {
364 valid: true,
365 signer_valid: false,
366 cosigner_valid: None,
367 errors: vec![],
368 error_codes: vec![],
369 policy_subcode: None,
370 };
371
372 result.signer_valid =
374 verify_signature(&public_keys.signer, message, &self.signatures.signer);
375 if !result.signer_valid {
376 result.valid = false;
377 result.errors.push("Invalid signer signature".to_string());
378 result.error_codes.push("VFY_SIGNATURE_INVALID".to_string());
379 }
380
381 if let (Some(sig), Some(pk)) = (&self.signatures.cosigner, &public_keys.cosigner) {
383 let valid = verify_signature(pk, message, sig);
384 result.cosigner_valid = Some(valid);
385 if !valid {
386 result.valid = false;
387 result.errors.push("Invalid cosigner signature".to_string());
388 result
389 .error_codes
390 .push("VFY_COSIGNATURE_INVALID".to_string());
391 }
392 }
393
394 result
395 }
396
397 pub fn to_json(&self) -> Result<String> {
399 Ok(serde_json::to_string_pretty(self)?)
400 }
401
402 pub fn from_json(json: &str) -> Result<Self> {
404 Ok(serde_json::from_str(json)?)
405 }
406}
407
408#[derive(Clone, Debug)]
410pub struct PublicKeySet {
411 pub signer: PublicKey,
413 pub cosigner: Option<PublicKey>,
415}
416
417impl PublicKeySet {
418 pub fn new(signer: PublicKey) -> Self {
420 Self {
421 signer,
422 cosigner: None,
423 }
424 }
425
426 pub fn with_cosigner(mut self, cosigner: PublicKey) -> Self {
428 self.cosigner = Some(cosigner);
429 self
430 }
431}
432
433#[derive(Clone, Debug, Serialize, Deserialize)]
435pub struct VerificationResult {
436 pub valid: bool,
438 pub signer_valid: bool,
440 pub cosigner_valid: Option<bool>,
442 pub errors: Vec<String>,
444 #[serde(default, skip_serializing_if = "Vec::is_empty")]
446 pub error_codes: Vec<String>,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
449 pub policy_subcode: Option<String>,
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 fn make_test_receipt() -> Receipt {
457 Receipt {
458 version: RECEIPT_SCHEMA_VERSION.to_string(),
459 receipt_id: Some("test-receipt-001".to_string()),
460 timestamp: "2026-01-01T00:00:00Z".to_string(),
461 content_hash: Hash::zero(),
462 verdict: Verdict::pass_with_gate("test-gate"),
463 provenance: Some(Provenance {
464 clawdstrike_version: Some("0.1.0".to_string()),
465 provider: Some("local".to_string()),
466 policy_hash: Some(Hash::zero()),
467 ruleset: Some("default".to_string()),
468 violations: vec![],
469 }),
470 metadata: None,
471 }
472 }
473
474 #[test]
475 fn test_sign_and_verify() {
476 let receipt = make_test_receipt();
477 let keypair = Keypair::generate();
478
479 let signed = SignedReceipt::sign(receipt, &keypair).unwrap();
480
481 let keys = PublicKeySet::new(keypair.public_key());
482 let result = signed.verify(&keys);
483
484 assert!(result.valid);
485 assert!(result.signer_valid);
486 }
487
488 #[test]
489 fn test_sign_with_cosigner() {
490 let receipt = make_test_receipt();
491 let signer_kp = Keypair::generate();
492 let cosigner_kp = Keypair::generate();
493
494 let mut signed = SignedReceipt::sign(receipt, &signer_kp).unwrap();
495 signed.add_cosigner(&cosigner_kp).unwrap();
496
497 let keys =
498 PublicKeySet::new(signer_kp.public_key()).with_cosigner(cosigner_kp.public_key());
499
500 let result = signed.verify(&keys);
501
502 assert!(result.valid);
503 assert!(result.signer_valid);
504 assert_eq!(result.cosigner_valid, Some(true));
505 }
506
507 #[test]
508 fn test_wrong_key_fails() {
509 let receipt = make_test_receipt();
510 let signer_kp = Keypair::generate();
511 let wrong_kp = Keypair::generate();
512
513 let signed = SignedReceipt::sign(receipt, &signer_kp).unwrap();
514
515 let keys = PublicKeySet::new(wrong_kp.public_key()); let result = signed.verify(&keys);
517
518 assert!(!result.valid);
519 assert!(!result.signer_valid);
520 assert!(result
521 .errors
522 .contains(&"Invalid signer signature".to_string()));
523 assert!(result
524 .error_codes
525 .contains(&"VFY_SIGNATURE_INVALID".to_string()));
526 }
527
528 #[test]
529 fn test_sign_rejects_unsupported_version() {
530 let mut receipt = make_test_receipt();
531 receipt.version = "2.0.0".to_string();
532 let signer_kp = Keypair::generate();
533
534 let err = SignedReceipt::sign(receipt, &signer_kp).unwrap_err();
535 assert!(err.to_string().contains("Unsupported receipt version"));
536 }
537
538 #[test]
539 fn test_verify_fails_closed_on_unsupported_version_before_signature_check() {
540 let receipt = make_test_receipt();
541 let signer_kp = Keypair::generate();
542
543 let mut signed = SignedReceipt::sign(receipt, &signer_kp).unwrap();
544 signed.receipt.version = "2.0.0".to_string();
545
546 let keys = PublicKeySet::new(signer_kp.public_key());
547 let result = signed.verify(&keys);
548
549 assert!(!result.valid);
550 assert_eq!(result.errors.len(), 1);
551 assert!(result.errors[0].contains("Unsupported receipt version"));
552 assert_eq!(
553 result.error_codes,
554 vec!["VFY_RECEIPT_VERSION_UNSUPPORTED".to_string()]
555 );
556 }
557
558 #[test]
559 fn test_canonical_json_deterministic() {
560 let receipt = make_test_receipt();
561 let json1 = receipt.to_canonical_json().unwrap();
562 let json2 = receipt.to_canonical_json().unwrap();
563 assert_eq!(json1, json2);
564 }
565
566 #[test]
567 fn test_canonical_json_sorted() {
568 let receipt = make_test_receipt();
569 let json = receipt.to_canonical_json().unwrap();
570
571 let content_pos = json.find("\"content_hash\"").unwrap();
574 let verdict_pos = json.find("\"verdict\"").unwrap();
575 assert!(content_pos < verdict_pos);
576 }
577
578 #[test]
579 fn test_serialization_roundtrip() {
580 let receipt = make_test_receipt();
581 let keypair = Keypair::generate();
582 let signed = SignedReceipt::sign(receipt, &keypair).unwrap();
583
584 let json = signed.to_json().unwrap();
585 let restored = SignedReceipt::from_json(&json).unwrap();
586
587 let keys = PublicKeySet::new(keypair.public_key());
588 let result = restored.verify(&keys);
589
590 assert!(result.valid);
591 }
592
593 #[test]
594 fn test_verdict_constructors() {
595 let pass = Verdict::pass();
596 assert!(pass.passed);
597
598 let fail = Verdict::fail();
599 assert!(!fail.passed);
600
601 let pass_gate = Verdict::pass_with_gate("my-gate");
602 assert!(pass_gate.passed);
603 assert_eq!(pass_gate.gate_id, Some("my-gate".to_string()));
604
605 let fail_gate = Verdict::fail_with_gate("my-gate");
606 assert!(!fail_gate.passed);
607 assert_eq!(fail_gate.gate_id, Some("my-gate".to_string()));
608 }
609
610 #[test]
611 fn test_receipt_builder() {
612 let receipt = Receipt::new(Hash::zero(), Verdict::pass())
613 .with_id("my-receipt")
614 .with_provenance(Provenance::default())
615 .with_metadata(serde_json::json!({"key": "value"}));
616
617 assert_eq!(receipt.receipt_id, Some("my-receipt".to_string()));
618 assert!(receipt.provenance.is_some());
619 assert!(receipt.metadata.is_some());
620 }
621
622 #[test]
623 fn test_receipt_metadata_merge() {
624 let receipt = Receipt::new(Hash::zero(), Verdict::pass())
625 .with_metadata(serde_json::json!({
626 "clawdstrike": {"extra_guards": ["a"]},
627 "hush": {"command": ["echo", "hi"]},
628 }))
629 .merge_metadata(serde_json::json!({
630 "clawdstrike": {"posture": {"state_after": "work"}},
631 "hush": {"events": "events.jsonl"},
632 }));
633
634 let metadata = receipt.metadata.expect("metadata");
635 assert_eq!(
636 metadata.pointer("/clawdstrike/extra_guards/0"),
637 Some(&serde_json::json!("a"))
638 );
639 assert_eq!(
640 metadata.pointer("/clawdstrike/posture/state_after"),
641 Some(&serde_json::json!("work"))
642 );
643 assert_eq!(
644 metadata.pointer("/hush/command/0"),
645 Some(&serde_json::json!("echo"))
646 );
647 assert_eq!(
648 metadata.pointer("/hush/events"),
649 Some(&serde_json::json!("events.jsonl"))
650 );
651 }
652}