Skip to main content

idprova_core/receipt/
log.rs

1use super::entry::Receipt;
2use crate::{IdprovaError, Result};
3
4/// An append-only, hash-chained receipt log.
5pub struct ReceiptLog {
6    entries: Vec<Receipt>,
7}
8
9impl ReceiptLog {
10    /// Create a new empty receipt log.
11    pub fn new() -> Self {
12        Self {
13            entries: Vec::new(),
14        }
15    }
16
17    /// Create a log from existing entries (e.g., loaded from disk).
18    pub fn from_entries(entries: Vec<Receipt>) -> Self {
19        Self { entries }
20    }
21
22    /// Append a receipt to the log.
23    pub fn append(&mut self, receipt: Receipt) {
24        self.entries.push(receipt);
25    }
26
27    /// Get the hash of the last receipt (for chain linking).
28    pub fn last_hash(&self) -> String {
29        self.entries
30            .last()
31            .map(|r| r.compute_hash())
32            .unwrap_or_else(|| "genesis".to_string())
33    }
34
35    /// Get the next sequence number.
36    pub fn next_sequence(&self) -> u64 {
37        self.entries
38            .last()
39            .map(|r| r.chain.sequence_number + 1)
40            .unwrap_or(0)
41    }
42
43    /// Verify the integrity of the hash chain (sequence numbers + previous_hash linkage).
44    ///
45    /// This does NOT verify receipt signatures — use `verify_integrity_with_key()` for
46    /// full cryptographic verification including signature checks.
47    pub fn verify_integrity(&self) -> Result<()> {
48        let mut expected_prev = "genesis".to_string();
49
50        for (i, receipt) in self.entries.iter().enumerate() {
51            if receipt.chain.sequence_number != i as u64 {
52                return Err(IdprovaError::ReceiptChainBroken(i as u64));
53            }
54            if receipt.chain.previous_hash != expected_prev {
55                return Err(IdprovaError::ReceiptChainBroken(i as u64));
56            }
57            expected_prev = receipt.compute_hash();
58        }
59
60        Ok(())
61    }
62
63    /// Verify full cryptographic integrity: hash chain linkage AND each receipt's signature.
64    ///
65    /// # Security: fix S2 (receipt signatures were never verified)
66    ///
67    /// Without signature verification, an attacker with write access can forge receipts
68    /// with correct hash chaining — invalidating the entire compliance audit trail.
69    ///
70    /// `public_key_bytes` is the Ed25519 public key of the agent that signed the receipts.
71    /// For multi-agent logs, use `verify_integrity_with_resolver()` (future).
72    pub fn verify_integrity_with_key(&self, public_key_bytes: &[u8; 32]) -> Result<()> {
73        let mut expected_prev = "genesis".to_string();
74
75        for (i, receipt) in self.entries.iter().enumerate() {
76            if receipt.chain.sequence_number != i as u64 {
77                return Err(IdprovaError::ReceiptChainBroken(i as u64));
78            }
79            if receipt.chain.previous_hash != expected_prev {
80                return Err(IdprovaError::ReceiptChainBroken(i as u64));
81            }
82
83            // Verify cryptographic signature on this receipt
84            receipt.verify_signature(public_key_bytes).map_err(|_| {
85                IdprovaError::InvalidReceipt(format!(
86                    "receipt {} (seq {i}) has invalid signature",
87                    receipt.id
88                ))
89            })?;
90
91            expected_prev = receipt.compute_hash();
92        }
93
94        Ok(())
95    }
96
97    /// Get all entries.
98    pub fn entries(&self) -> &[Receipt] {
99        &self.entries
100    }
101
102    /// Get the number of entries.
103    pub fn len(&self) -> usize {
104        self.entries.len()
105    }
106
107    pub fn is_empty(&self) -> bool {
108        self.entries.is_empty()
109    }
110}
111
112impl Default for ReceiptLog {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::crypto::KeyPair;
122    use crate::receipt::entry::{ActionDetails, ChainLink};
123    use chrono::Utc;
124
125    fn make_signed_receipt(kp: &KeyPair, seq: u64, prev_hash: &str) -> Receipt {
126        let chain = ChainLink {
127            previous_hash: prev_hash.to_string(),
128            sequence_number: seq,
129        };
130        let action = ActionDetails {
131            action_type: "mcp:tool-call".to_string(),
132            server: None,
133            tool: None,
134            input_hash: "blake3:test".to_string(),
135            output_hash: None,
136            status: "success".to_string(),
137            duration_ms: None,
138        };
139        let mut r = Receipt {
140            id: format!("rcpt_{seq}"),
141            timestamp: Utc::now(),
142            agent: "did:idprova:example.com:agent".to_string(),
143            dat: "dat_test".to_string(),
144            action,
145            context: None,
146            chain,
147            signature: String::new(),
148        };
149        let sig = kp.sign(&r.signing_payload_bytes());
150        r.signature = hex::encode(sig);
151        r
152    }
153
154    fn build_log(kp: &KeyPair, count: usize) -> ReceiptLog {
155        let mut log = ReceiptLog::new();
156        for i in 0..count {
157            let prev = log.last_hash();
158            let r = make_signed_receipt(kp, i as u64, &prev);
159            log.append(r);
160        }
161        log
162    }
163
164    #[test]
165    fn test_verify_integrity_passes_for_valid_chain() {
166        let kp = KeyPair::generate();
167        let log = build_log(&kp, 5);
168        assert!(log.verify_integrity().is_ok());
169    }
170
171    /// S2: verify_integrity_with_key() must catch forged receipts.
172    ///
173    /// An attacker with write access can create a receipt with correct hash
174    /// chaining but an invalid signature. This must be rejected.
175    #[test]
176    fn test_s2_forged_receipt_rejected_by_integrity_with_key() {
177        let kp = KeyPair::generate();
178        let mut log = build_log(&kp, 3);
179        let pub_bytes = kp.public_key_bytes();
180
181        // Passes with correct key
182        assert!(log.verify_integrity_with_key(&pub_bytes).is_ok());
183
184        // Forge the last receipt by mutating the action after signing
185        let last = log.entries.last_mut().unwrap();
186        last.action.status = "forged_by_attacker".to_string();
187
188        // Hash chain still passes (attacker got the structure right)
189        // but signature check must catch the tampering
190        assert!(
191            log.verify_integrity_with_key(&pub_bytes).is_err(),
192            "forged receipt must be rejected by verify_integrity_with_key"
193        );
194    }
195
196    #[test]
197    fn test_verify_integrity_with_key_rejects_wrong_key() {
198        let kp1 = KeyPair::generate();
199        let kp2 = KeyPair::generate();
200        let log = build_log(&kp1, 3);
201        let wrong_pub = kp2.public_key_bytes();
202        assert!(
203            log.verify_integrity_with_key(&wrong_pub).is_err(),
204            "wrong key must fail verify_integrity_with_key"
205        );
206    }
207}