Skip to main content

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}