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/// A chain proves nothing is missing before its last entry, not that its last entry is
160/// the last one written: a log cut short at the end is still a valid chain. To catch
161/// that, compare the last entry's index and digest with the ones the device last
162/// reported, in a heartbeat or a telemetry snapshot.
163///
164/// # Arguments
165///
166/// * `public` - the public identity expected to have signed the log.
167/// * `entries` - the log's entries, oldest first.
168///
169/// # Returns
170///
171/// `Ok(())` if every entry is in sequence, correctly chained, and authentic.
172///
173/// # Errors
174///
175/// Returns [`Error::Auth`](pamoja_core::Error::Auth) at the first entry that is out
176/// of sequence, broken in the chain, or not authentically signed.
177pub fn verify_chain(public: &PublicIdentity, entries: &[Entry]) -> Result<()> {
178 let mut verifier = Verifier::new(*public);
179 for entry in entries {
180 verifier.check(entry)?;
181 }
182 Ok(())
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 fn device() -> DeviceIdentity {
190 DeviceIdentity::from_seed(&[1u8; 32])
191 }
192
193 fn sample_log() -> (alloc::vec::Vec<Entry>, PublicIdentity) {
194 let signer = device();
195 let public = signer.public();
196 let mut log = AuditLog::new(signer);
197 let entries = alloc::vec![log.append(b"r0"), log.append(b"r1"), log.append(b"r2")];
198 (entries, public)
199 }
200
201 #[test]
202 fn a_genuine_chain_verifies() {
203 let (entries, public) = sample_log();
204 assert!(verify_chain(&public, &entries).is_ok());
205 }
206
207 #[test]
208 fn a_tampered_payload_is_detected() {
209 let (mut entries, public) = sample_log();
210 let mut bytes = entries[1].to_bytes();
211 *bytes.last_mut().expect("non-empty entry") ^= 0xff;
212 entries[1] = Entry::from_bytes(&bytes).expect("parse");
213 assert!(matches!(
214 verify_chain(&public, &entries),
215 Err(Error::Auth(_))
216 ));
217 }
218
219 #[test]
220 fn a_reordered_chain_is_detected() {
221 let (mut entries, public) = sample_log();
222 entries.swap(1, 2);
223 assert!(matches!(
224 verify_chain(&public, &entries),
225 Err(Error::Auth(_))
226 ));
227 }
228
229 #[test]
230 fn a_dropped_entry_is_detected() {
231 let (entries, public) = sample_log();
232 let gap = alloc::vec![entries[0].clone(), entries[2].clone()];
233 assert!(matches!(verify_chain(&public, &gap), Err(Error::Auth(_))));
234 }
235
236 #[test]
237 fn another_signer_does_not_verify() {
238 let (entries, _) = sample_log();
239 let stranger = DeviceIdentity::from_seed(&[2u8; 32]).public();
240 assert!(verify_chain(&stranger, &entries).is_err());
241 }
242
243 #[test]
244 fn an_empty_chain_is_trivially_valid() {
245 let public = device().public();
246 assert!(verify_chain(&public, &[]).is_ok());
247 }
248
249 #[test]
250 fn a_chain_that_does_not_start_at_the_beginning_is_rejected() {
251 let (entries, public) = sample_log();
252 // The slice's first entry has index 1, but verification expects to begin at 0.
253 assert!(matches!(
254 verify_chain(&public, &entries[1..]),
255 Err(Error::Auth(_))
256 ));
257 }
258
259 #[test]
260 fn resume_continues_the_chain() {
261 let public = device().public();
262 let mut log = AuditLog::new(device());
263 let e0 = log.append(b"r0");
264 let e1 = log.append(b"r1");
265
266 // A restart: rebuild the log from the last stored entry and keep appending.
267 let mut resumed = AuditLog::resume(device(), &e1);
268 let e2 = resumed.append(b"r2");
269
270 assert!(verify_chain(&public, &[e0, e1, e2]).is_ok());
271 }
272
273 #[test]
274 fn a_chain_cut_short_at_the_end_still_verifies() {
275 let (entries, public) = sample_log();
276 // What is left is a valid chain, so the tail is caught only against what the
277 // device last reported.
278 assert!(verify_chain(&public, &entries[..2]).is_ok());
279 assert_ne!(entries[1].index(), entries[2].index());
280 }
281}