idprova_core/receipt/
log.rs1use super::entry::Receipt;
2use crate::{IdprovaError, Result};
3
4pub struct ReceiptLog {
6 entries: Vec<Receipt>,
7}
8
9impl ReceiptLog {
10 pub fn new() -> Self {
12 Self {
13 entries: Vec::new(),
14 }
15 }
16
17 pub fn from_entries(entries: Vec<Receipt>) -> Self {
19 Self { entries }
20 }
21
22 pub fn append(&mut self, receipt: Receipt) {
24 self.entries.push(receipt);
25 }
26
27 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 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 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 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 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 pub fn entries(&self) -> &[Receipt] {
99 &self.entries
100 }
101
102 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 #[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 assert!(log.verify_integrity_with_key(&pub_bytes).is_ok());
183
184 let last = log.entries.last_mut().unwrap();
186 last.action.status = "forged_by_attacker".to_string();
187
188 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}