Skip to main content

pamoja_audit/
entry.rs

1//! A single entry in a signed, hash-chained audit log.
2
3use alloc::vec::Vec;
4
5use pamoja_core::{Error, Result};
6use pamoja_security::Signature;
7use sha2::{Digest, Sha256};
8
9// The fixed header before each payload: the index, the previous hash, the signature.
10const HEADER_LEN: usize = 8 + 32 + 64;
11
12/// One entry in a tamper-evident audit log.
13///
14/// An entry binds a payload both to its position in the log and to the entry before
15/// it: it carries the payload, the entry's index, the digest of the previous entry
16/// (the chain link), and a signature over this entry's digest. Because each entry
17/// commits to the one before, altering, reordering, inserting, or dropping any entry
18/// breaks the chain and is caught on verification.
19#[derive(Clone, Debug)]
20pub struct Entry {
21    index: u64,
22    prev: [u8; 32],
23    signature: Signature,
24    payload: Vec<u8>,
25}
26
27impl Entry {
28    pub(crate) fn new(index: u64, prev: [u8; 32], signature: Signature, payload: Vec<u8>) -> Self {
29        Self {
30            index,
31            prev,
32            signature,
33            payload,
34        }
35    }
36
37    /// Returns this entry's position in the log, counting from zero.
38    ///
39    /// # Returns
40    ///
41    /// The entry index.
42    pub fn index(&self) -> u64 {
43        self.index
44    }
45
46    /// Returns the digest of the previous entry that this entry chains to.
47    ///
48    /// # Returns
49    ///
50    /// The previous entry's digest, or all zeros for the first entry.
51    pub fn previous(&self) -> [u8; 32] {
52        self.prev
53    }
54
55    /// Returns the entry's payload, such as an encoded reading.
56    ///
57    /// # Returns
58    ///
59    /// The payload bytes.
60    pub fn payload(&self) -> &[u8] {
61        &self.payload
62    }
63
64    /// Returns the signature over this entry's digest.
65    ///
66    /// # Returns
67    ///
68    /// The entry's [`Signature`].
69    pub fn signature(&self) -> &Signature {
70        &self.signature
71    }
72
73    /// Computes this entry's digest: the hash the signature covers and the next
74    /// entry chains to.
75    ///
76    /// # Returns
77    ///
78    /// The 32-byte SHA-256 digest over the index, previous digest, and payload.
79    pub fn digest(&self) -> [u8; 32] {
80        digest(self.index, &self.prev, &self.payload)
81    }
82
83    /// Encodes the entry to bytes for durable storage.
84    ///
85    /// The layout is the little-endian index, the previous digest, the signature,
86    /// then the payload.
87    ///
88    /// # Returns
89    ///
90    /// The encoded entry.
91    pub fn to_bytes(&self) -> Vec<u8> {
92        let mut bytes = Vec::with_capacity(HEADER_LEN + self.payload.len());
93        bytes.extend_from_slice(&self.index.to_le_bytes());
94        bytes.extend_from_slice(&self.prev);
95        bytes.extend_from_slice(&self.signature.to_bytes());
96        bytes.extend_from_slice(&self.payload);
97        bytes
98    }
99
100    /// Decodes an entry from its stored bytes.
101    ///
102    /// # Arguments
103    ///
104    /// * `bytes` - the encoded entry, as produced by [`to_bytes`](Entry::to_bytes).
105    ///
106    /// # Returns
107    ///
108    /// The decoded entry.
109    ///
110    /// # Errors
111    ///
112    /// Returns [`Error::Codec`](pamoja_core::Error::Codec) if `bytes` is shorter than
113    /// an entry header.
114    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
115        if bytes.len() < HEADER_LEN {
116            return Err(Error::Codec(
117                "audit entry is shorter than its header".into(),
118            ));
119        }
120        let index = u64::from_le_bytes(bytes[..8].try_into().expect("eight index bytes"));
121        let prev: [u8; 32] = bytes[8..40].try_into().expect("thirty-two previous bytes");
122        let signature: [u8; 64] = bytes[40..HEADER_LEN]
123            .try_into()
124            .expect("sixty-four signature bytes");
125        let payload = bytes[HEADER_LEN..].to_vec();
126        Ok(Self::new(
127            index,
128            prev,
129            Signature::from_bytes(&signature),
130            payload,
131        ))
132    }
133}
134
135// The digest the signature covers and the next entry chains to.
136pub(crate) fn digest(index: u64, prev: &[u8; 32], payload: &[u8]) -> [u8; 32] {
137    let mut hasher = Sha256::new();
138    hasher.update(index.to_le_bytes());
139    hasher.update(prev);
140    hasher.update(payload);
141    let out = hasher.finalize();
142    let mut digest = [0u8; 32];
143    digest.copy_from_slice(&out);
144    digest
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use pamoja_security::DeviceIdentity;
151
152    #[test]
153    fn an_entry_round_trips_through_bytes() {
154        let device = DeviceIdentity::from_seed(&[1u8; 32]);
155        let signature = device.sign(b"x");
156        let entry = Entry::new(3, [7u8; 32], signature, b"payload".to_vec());
157
158        let bytes = entry.to_bytes();
159        let restored = Entry::from_bytes(&bytes).expect("parse");
160
161        assert_eq!(restored.index(), 3);
162        assert_eq!(restored.previous(), [7u8; 32]);
163        assert_eq!(restored.payload(), b"payload");
164        assert_eq!(restored.to_bytes(), bytes);
165    }
166
167    #[test]
168    fn a_short_buffer_is_rejected() {
169        let result = Entry::from_bytes(&[0u8; 10]);
170        assert!(matches!(result, Err(Error::Codec(_))));
171    }
172}