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