Skip to main content

hush_core/
receipt.rs

1//! Receipt types and signing for attestation
2
3use 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
10/// Current receipt schema version.
11///
12/// This is a schema compatibility boundary (not the crate version). Verifiers must fail closed on
13/// unsupported versions to prevent silent drift.
14pub const RECEIPT_SCHEMA_VERSION: &str = "1.0.0";
15
16/// Validate that a receipt version string is supported.
17pub 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/// Verdict result from quality gates or guards
60#[derive(Clone, Debug, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct Verdict {
63    /// Whether the check passed
64    pub passed: bool,
65    /// Optional gate or guard identifier
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub gate_id: Option<String>,
68    /// Optional scores (guard-specific)
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub scores: Option<JsonValue>,
71    /// Optional threshold
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub threshold: Option<f64>,
74}
75
76impl Verdict {
77    /// Create a passing verdict
78    pub fn pass() -> Self {
79        Self {
80            passed: true,
81            gate_id: None,
82            scores: None,
83            threshold: None,
84        }
85    }
86
87    /// Create a failing verdict
88    pub fn fail() -> Self {
89        Self {
90            passed: false,
91            gate_id: None,
92            scores: None,
93            threshold: None,
94        }
95    }
96
97    /// Create a passing verdict with gate ID
98    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    /// Create a failing verdict with gate ID
108    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/// Violation reference from a guard
119#[derive(Clone, Debug, Serialize, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct ViolationRef {
122    /// Guard that detected the violation
123    pub guard: String,
124    /// Severity level
125    pub severity: String,
126    /// Human-readable message
127    pub message: String,
128    /// Action taken (e.g., "blocked", "logged")
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub action: Option<String>,
131}
132
133/// Provenance information about execution environment
134#[derive(Clone, Debug, Default, Serialize, Deserialize)]
135#[serde(deny_unknown_fields)]
136pub struct Provenance {
137    /// Clawdstrike version
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub clawdstrike_version: Option<String>,
140    /// Execution provider
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub provider: Option<String>,
143    /// Policy configuration hash
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub policy_hash: Option<Hash>,
146    /// Ruleset identifier
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub ruleset: Option<String>,
149    /// Any violations detected during execution
150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
151    pub violations: Vec<ViolationRef>,
152}
153
154/// Receipt for an attested execution (unsigned)
155#[derive(Clone, Debug, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct Receipt {
158    /// Receipt schema version
159    pub version: String,
160    /// Unique receipt identifier
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub receipt_id: Option<String>,
163    /// ISO-8601 timestamp
164    pub timestamp: String,
165    /// Content hash (what was executed/verified)
166    pub content_hash: Hash,
167    /// Overall verdict
168    pub verdict: Verdict,
169    /// Execution provenance
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub provenance: Option<Provenance>,
172    /// Additional metadata
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub metadata: Option<JsonValue>,
175}
176
177impl Receipt {
178    /// Create a new receipt
179    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    /// Set receipt ID
192    pub fn with_id(mut self, id: impl Into<String>) -> Self {
193        self.receipt_id = Some(id.into());
194        self
195    }
196
197    /// Set provenance
198    pub fn with_provenance(mut self, provenance: Provenance) -> Self {
199        self.provenance = Some(provenance);
200        self
201    }
202
203    /// Set metadata
204    pub fn with_metadata(mut self, metadata: JsonValue) -> Self {
205        self.metadata = Some(metadata);
206        self
207    }
208
209    /// Merge metadata with existing metadata using deep object merge semantics.
210    ///
211    /// - object + object: recursive key merge
212    /// - any other source value: replaces target
213    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    /// Validate that this receipt uses a supported schema version.
223    pub fn validate_version(&self) -> Result<()> {
224        validate_receipt_version(&self.version)
225    }
226
227    /// Serialize to canonical JSON (sorted keys, no extra whitespace)
228    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    /// Compute SHA-256 hash of canonical JSON
235    pub fn hash_sha256(&self) -> Result<Hash> {
236        let canonical = self.to_canonical_json()?;
237        Ok(sha256(canonical.as_bytes()))
238    }
239
240    /// Compute Keccak-256 hash of canonical JSON (for Ethereum)
241    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/// Signatures on a receipt
276#[derive(Clone, Debug, Serialize, Deserialize)]
277#[serde(deny_unknown_fields)]
278pub struct Signatures {
279    /// Primary signer (required)
280    pub signer: Signature,
281    /// Optional co-signer
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub cosigner: Option<Signature>,
284}
285
286/// Signed receipt
287#[derive(Clone, Debug, Serialize, Deserialize)]
288#[serde(deny_unknown_fields)]
289pub struct SignedReceipt {
290    /// The underlying receipt
291    pub receipt: Receipt,
292    /// Signatures
293    pub signatures: Signatures,
294}
295
296impl SignedReceipt {
297    /// Sign a receipt
298    pub fn sign(receipt: Receipt, keypair: &Keypair) -> Result<Self> {
299        Self::sign_with(receipt, keypair)
300    }
301
302    /// Sign a receipt with an abstract signer (e.g., a TPM-backed signer).
303    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    /// Add co-signer signature
318    pub fn add_cosigner(&mut self, keypair: &Keypair) -> Result<()> {
319        self.add_cosigner_with(keypair)
320    }
321
322    /// Add co-signer signature with an abstract signer.
323    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    /// Verify all signatures
331    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        // Verify primary signature (required)
373        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        // Verify co-signer signature (optional)
382        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    /// Serialize to JSON
398    pub fn to_json(&self) -> Result<String> {
399        Ok(serde_json::to_string_pretty(self)?)
400    }
401
402    /// Parse from JSON
403    pub fn from_json(json: &str) -> Result<Self> {
404        Ok(serde_json::from_str(json)?)
405    }
406}
407
408/// Set of public keys for verification
409#[derive(Clone, Debug)]
410pub struct PublicKeySet {
411    /// Primary signer public key
412    pub signer: PublicKey,
413    /// Optional co-signer public key
414    pub cosigner: Option<PublicKey>,
415}
416
417impl PublicKeySet {
418    /// Create with just the primary signer
419    pub fn new(signer: PublicKey) -> Self {
420        Self {
421            signer,
422            cosigner: None,
423        }
424    }
425
426    /// Add a co-signer
427    pub fn with_cosigner(mut self, cosigner: PublicKey) -> Self {
428        self.cosigner = Some(cosigner);
429        self
430    }
431}
432
433/// Result of receipt verification
434#[derive(Clone, Debug, Serialize, Deserialize)]
435pub struct VerificationResult {
436    /// Overall validity
437    pub valid: bool,
438    /// Primary signer signature valid
439    pub signer_valid: bool,
440    /// Co-signer signature valid (if present)
441    pub cosigner_valid: Option<bool>,
442    /// Error messages
443    pub errors: Vec<String>,
444    /// Stable verifier error codes (VFY_* taxonomy)
445    #[serde(default, skip_serializing_if = "Vec::is_empty")]
446    pub error_codes: Vec<String>,
447    /// Optional attestation-policy subcode (AVP_*)
448    #[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()); // Wrong key!
516        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        // Check that keys appear in alphabetical order
572        // "content_hash" should come before "verdict"
573        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}