pamoja_audit/log.rs
1//! Building and verifying a signed, hash-chained audit log.
2
3use pamoja_core::{Error, Result};
4use pamoja_security::{DeviceIdentity, PublicIdentity};
5
6use crate::entry::{digest, Entry};
7
8// The previous-digest a log starts from, before any entry exists.
9const GENESIS: [u8; 32] = [0u8; 32];
10
11/// Appends signed, hash-chained entries to a tamper-evident log.
12///
13/// Each [`append`](AuditLog::append) signs the new entry's digest and links it to
14/// the previous entry, so the log can later be proven complete and unaltered. The
15/// log holds the signing identity and the chain head; it does not store the entries
16/// itself, so the caller persists each entry's [`to_bytes`](Entry::to_bytes) to
17/// durable storage (a file or SD card in the field) and rebuilds the chain from
18/// there.
19///
20/// # Examples
21///
22/// ```
23/// use pamoja_audit::{verify_chain, AuditLog};
24/// use pamoja_security::DeviceIdentity;
25///
26/// let device = DeviceIdentity::from_seed(&[9u8; 32]);
27/// let public = device.public();
28///
29/// let mut log = AuditLog::new(device);
30/// let entries = [log.append(b"4.6C"), log.append(b"4.9C")];
31///
32/// assert!(verify_chain(&public, &entries).is_ok());
33/// ```
34pub struct AuditLog {
35 identity: DeviceIdentity,
36 head: [u8; 32],
37 next_index: u64,
38}
39
40impl AuditLog {
41 /// Starts a fresh log signed by `identity`.
42 ///
43 /// # Arguments
44 ///
45 /// * `identity` - the device identity that signs each entry.
46 ///
47 /// # Returns
48 ///
49 /// An empty log positioned at the first entry.
50 pub fn new(identity: DeviceIdentity) -> Self {
51 Self {
52 identity,
53 head: GENESIS,
54 next_index: 0,
55 }
56 }
57
58 /// Resumes a log after its last entry, to keep appending across a restart.
59 ///
60 /// # Arguments
61 ///
62 /// * `identity` - the device identity that signs each entry.
63 /// * `last` - the most recent entry already in durable storage.
64 ///
65 /// # Returns
66 ///
67 /// A log positioned to append after `last`.
68 pub fn resume(identity: DeviceIdentity, last: &Entry) -> Self {
69 Self {
70 identity,
71 head: last.digest(),
72 next_index: last.index() + 1,
73 }
74 }
75
76 /// Appends `payload`, returning the new signed, chained entry.
77 ///
78 /// The caller persists the returned entry's [`to_bytes`](Entry::to_bytes).
79 ///
80 /// # Arguments
81 ///
82 /// * `payload` - the bytes to record, such as an encoded reading.
83 ///
84 /// # Returns
85 ///
86 /// The new [`Entry`].
87 pub fn append(&mut self, payload: &[u8]) -> Entry {
88 let index = self.next_index;
89 let prev = self.head;
90 let digest = digest(index, &prev, payload);
91 let signature = self.identity.sign(&digest);
92 self.head = digest;
93 self.next_index += 1;
94 Entry::new(index, prev, signature, payload.to_vec())
95 }
96}
97
98/// Verifies a log's entries in sequence against the signer's public identity.
99///
100/// A verifier checks each entry's index, its link to the previous entry, and its
101/// signature, advancing only on success. Feed it entries oldest first; the first
102/// failure is the point the log was tampered with.
103pub struct Verifier {
104 public: PublicIdentity,
105 expected_index: u64,
106 expected_prev: [u8; 32],
107}
108
109impl Verifier {
110 /// Creates a verifier for a log signed by `public`, starting at the first entry.
111 ///
112 /// # Arguments
113 ///
114 /// * `public` - the public identity expected to have signed the log.
115 ///
116 /// # Returns
117 ///
118 /// A verifier positioned at the first entry.
119 pub fn new(public: PublicIdentity) -> Self {
120 Self {
121 public,
122 expected_index: 0,
123 expected_prev: GENESIS,
124 }
125 }
126
127 /// Verifies the next entry in sequence, advancing the verifier on success.
128 ///
129 /// # Arguments
130 ///
131 /// * `entry` - the next entry in the log.
132 ///
133 /// # Returns
134 ///
135 /// `Ok(())` if the entry is in sequence, correctly chained, and authentically
136 /// signed.
137 ///
138 /// # Errors
139 ///
140 /// Returns [`Error::Auth`](pamoja_core::Error::Auth) if the entry is out of
141 /// sequence, its chain link is wrong, or its signature does not verify.
142 pub fn check(&mut self, entry: &Entry) -> Result<()> {
143 if entry.index() != self.expected_index {
144 return Err(Error::Auth("audit entry is out of sequence".into()));
145 }
146 if entry.previous() != self.expected_prev {
147 return Err(Error::Auth("audit chain is broken".into()));
148 }
149 let digest = entry.digest();
150 self.public.verify(&digest, entry.signature())?;
151 self.expected_index += 1;
152 self.expected_prev = digest;
153 Ok(())
154 }
155}
156
157/// Verifies a whole chain of entries from the start against `public`.
158///
159/// # Arguments
160///
161/// * `public` - the public identity expected to have signed the log.
162/// * `entries` - the log's entries, oldest first.
163///
164/// # Returns
165///
166/// `Ok(())` if every entry is in sequence, correctly chained, and authentic.
167///
168/// # Errors
169///
170/// Returns [`Error::Auth`](pamoja_core::Error::Auth) at the first entry that is out
171/// of sequence, broken in the chain, or not authentically signed.
172pub fn verify_chain(public: &PublicIdentity, entries: &[Entry]) -> Result<()> {
173 let mut verifier = Verifier::new(*public);
174 for entry in entries {
175 verifier.check(entry)?;
176 }
177 Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 fn device() -> DeviceIdentity {
185 DeviceIdentity::from_seed(&[1u8; 32])
186 }
187
188 fn sample_log() -> (alloc::vec::Vec<Entry>, PublicIdentity) {
189 let signer = device();
190 let public = signer.public();
191 let mut log = AuditLog::new(signer);
192 let entries = alloc::vec![log.append(b"r0"), log.append(b"r1"), log.append(b"r2")];
193 (entries, public)
194 }
195
196 #[test]
197 fn a_genuine_chain_verifies() {
198 let (entries, public) = sample_log();
199 assert!(verify_chain(&public, &entries).is_ok());
200 }
201
202 #[test]
203 fn a_tampered_payload_is_detected() {
204 let (mut entries, public) = sample_log();
205 let mut bytes = entries[1].to_bytes();
206 *bytes.last_mut().expect("non-empty entry") ^= 0xff;
207 entries[1] = Entry::from_bytes(&bytes).expect("parse");
208 assert!(matches!(
209 verify_chain(&public, &entries),
210 Err(Error::Auth(_))
211 ));
212 }
213
214 #[test]
215 fn a_reordered_chain_is_detected() {
216 let (mut entries, public) = sample_log();
217 entries.swap(1, 2);
218 assert!(matches!(
219 verify_chain(&public, &entries),
220 Err(Error::Auth(_))
221 ));
222 }
223
224 #[test]
225 fn a_dropped_entry_is_detected() {
226 let (entries, public) = sample_log();
227 let gap = alloc::vec![entries[0].clone(), entries[2].clone()];
228 assert!(matches!(verify_chain(&public, &gap), Err(Error::Auth(_))));
229 }
230
231 #[test]
232 fn another_signer_does_not_verify() {
233 let (entries, _) = sample_log();
234 let stranger = DeviceIdentity::from_seed(&[2u8; 32]).public();
235 assert!(verify_chain(&stranger, &entries).is_err());
236 }
237
238 #[test]
239 fn an_empty_chain_is_trivially_valid() {
240 let public = device().public();
241 assert!(verify_chain(&public, &[]).is_ok());
242 }
243
244 #[test]
245 fn a_chain_that_does_not_start_at_the_beginning_is_rejected() {
246 let (entries, public) = sample_log();
247 // The slice's first entry has index 1, but verification expects to begin at 0.
248 assert!(matches!(
249 verify_chain(&public, &entries[1..]),
250 Err(Error::Auth(_))
251 ));
252 }
253
254 #[test]
255 fn resume_continues_the_chain() {
256 let public = device().public();
257 let mut log = AuditLog::new(device());
258 let e0 = log.append(b"r0");
259 let e1 = log.append(b"r1");
260
261 // A restart: rebuild the log from the last stored entry and keep appending.
262 let mut resumed = AuditLog::resume(device(), &e1);
263 let e2 = resumed.append(b"r2");
264
265 assert!(verify_chain(&public, &[e0, e1, e2]).is_ok());
266 }
267}