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/// # 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}