Skip to main content

pamoja_update/
verify.rs

1//! Checking an image against the manifest that describes it.
2//!
3//! The image is hashed as it arrives rather than after it lands, so a device with
4//! kilobytes of RAM can verify a payload of megabytes. Nothing is trusted until
5//! [`ImageVerifier::finish`] agrees on both the length and the digest, which is
6//! why the caller is handed a receipt it cannot forge rather than a boolean it
7//! might forget to read.
8
9use sha2::{Digest, Sha256};
10
11use crate::error::{Refusal, Result};
12use crate::manifest::{Manifest, DIGEST_LEN};
13
14/// Proof that an image matched its manifest.
15///
16/// Only [`ImageVerifier::finish`] produces one, so a function that asks for a
17/// [`Verified`] cannot be handed an unchecked image by mistake.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub struct Verified {
20    size: u32,
21    digest: [u8; DIGEST_LEN],
22}
23
24impl Verified {
25    /// Returns the verified image's length in bytes.
26    ///
27    /// # Returns
28    ///
29    /// The length the manifest declared and the image turned out to have.
30    pub fn size(&self) -> u32 {
31        self.size
32    }
33
34    /// Returns the verified image's digest.
35    ///
36    /// # Returns
37    ///
38    /// The SHA-256 the manifest committed to and the image hashed to.
39    pub fn digest(&self) -> [u8; DIGEST_LEN] {
40        self.digest
41    }
42}
43
44/// Hashes a complete image, for a publisher filling in a manifest.
45///
46/// The manifest commits to a SHA-256 over the image, and this is that hash, so a
47/// publisher does not have to add a hashing crate of its own just to name the image it
48/// is releasing. A device receiving the image checks the same hash through
49/// [`ImageVerifier`], which streams it rather than holding the whole image.
50///
51/// # Arguments
52///
53/// * `image` - the complete image the release carries.
54///
55/// # Returns
56///
57/// The SHA-256 of `image`, ready to put in a [`Manifest`](crate::Manifest).
58///
59/// # Examples
60///
61/// ```
62/// use pamoja_update::image_digest;
63///
64/// let digest = image_digest(b"firmware");
65/// assert_eq!(digest.len(), 32);
66/// ```
67pub fn image_digest(image: &[u8]) -> [u8; DIGEST_LEN] {
68    Sha256::digest(image).into()
69}
70
71/// Hashes an image as it arrives and checks it against a manifest.
72///
73/// # Examples
74///
75/// ```
76/// use pamoja_update::{image_digest, ImageVerifier, Manifest, PayloadFormat, STRUCTURE_VERSION};
77///
78/// const VENDOR: [u8; 16] = [10; 16];
79/// const GATE_CONTROLLER: [u8; 16] = [15; 16];
80///
81/// let image = b"firmware for a gate controller";
82/// let manifest = Manifest {
83///     structure_version: STRUCTURE_VERSION,
84///     sequence: 1,
85///     vendor_id: VENDOR,
86///     class_id: GATE_CONTROLLER,
87///     format: PayloadFormat::Raw,
88///     storage: 0,
89///     digest: image_digest(image),
90///     size: image.len() as u32,
91///     expires: 0,
92/// };
93///
94/// let mut verifier = ImageVerifier::new(&manifest);
95/// for chunk in image.chunks(4) {
96///     verifier.update(chunk).unwrap();
97/// }
98/// let verified = verifier.finish().unwrap();
99/// assert_eq!(verified.size(), manifest.size);
100/// ```
101pub struct ImageVerifier {
102    hasher: Sha256,
103    expected_digest: [u8; DIGEST_LEN],
104    expected_size: u32,
105    seen: u64,
106}
107
108impl ImageVerifier {
109    /// Starts verifying an image against `manifest`.
110    ///
111    /// # Arguments
112    ///
113    /// * `manifest` - the manifest whose digest and size the image must match.
114    ///
115    /// # Returns
116    ///
117    /// A verifier awaiting the image.
118    pub fn new(manifest: &Manifest) -> Self {
119        Self {
120            hasher: Sha256::new(),
121            expected_digest: manifest.digest,
122            expected_size: manifest.size,
123            seen: 0,
124        }
125    }
126
127    /// Folds the next chunk of the image in.
128    ///
129    /// # Arguments
130    ///
131    /// * `chunk` - the next bytes of the image, in order.
132    ///
133    /// # Returns
134    ///
135    /// `Ok(())` once the chunk is hashed.
136    ///
137    /// # Errors
138    ///
139    /// Returns [`Refusal::Size`] as soon as more bytes arrive than the manifest
140    /// declared, so an oversized payload is stopped while it is arriving rather
141    /// than after it has filled the slot.
142    pub fn update(&mut self, chunk: &[u8]) -> Result<()> {
143        self.seen += chunk.len() as u64;
144        if self.seen > u64::from(self.expected_size) {
145            return Err(Refusal::Size);
146        }
147        self.hasher.update(chunk);
148        Ok(())
149    }
150
151    /// Finishes the check and reports whether the image is the one described.
152    ///
153    /// # Returns
154    ///
155    /// A [`Verified`] receipt when the image is exactly the length and content
156    /// the manifest committed to.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`Refusal::Size`] if fewer bytes arrived than declared, or
161    /// [`Refusal::Digest`] if the content does not hash to the manifest's digest.
162    pub fn finish(self) -> Result<Verified> {
163        if self.seen != u64::from(self.expected_size) {
164            return Err(Refusal::Size);
165        }
166
167        let digest: [u8; DIGEST_LEN] = self.hasher.finalize().into();
168        // A wrong digest and a right one take the same work to reject here, but the
169        // comparison is over a hash the attacker cannot steer, so an early return
170        // leaks nothing worth having.
171        if digest != self.expected_digest {
172            return Err(Refusal::Digest);
173        }
174
175        Ok(Verified {
176            size: self.expected_size,
177            digest,
178        })
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::manifest::{PayloadFormat, STRUCTURE_VERSION};
186
187    /// A manifest describing `image` exactly.
188    fn manifest_for(image: &[u8]) -> Manifest {
189        Manifest {
190            structure_version: STRUCTURE_VERSION,
191            sequence: 1,
192            vendor_id: [0; 16],
193            class_id: [0; 16],
194            format: PayloadFormat::Raw,
195            storage: 0,
196            digest: Sha256::digest(image).into(),
197            size: image.len() as u32,
198            expires: 0,
199        }
200    }
201
202    /// Feeds an image through a verifier in small chunks.
203    fn run(manifest: &Manifest, image: &[u8]) -> Result<Verified> {
204        let mut verifier = ImageVerifier::new(manifest);
205        for chunk in image.chunks(7) {
206            verifier.update(chunk)?;
207        }
208        verifier.finish()
209    }
210
211    #[test]
212    fn the_described_image_verifies() {
213        let image = b"the firmware, arriving in pieces";
214        let manifest = manifest_for(image);
215        let verified = run(&manifest, image).expect("verify");
216        assert_eq!(verified.size(), image.len() as u32);
217        assert_eq!(verified.digest(), manifest.digest);
218    }
219
220    #[test]
221    fn an_empty_image_verifies_when_that_is_what_was_described() {
222        let manifest = manifest_for(b"");
223        assert!(run(&manifest, b"").is_ok());
224    }
225
226    #[test]
227    fn a_tampered_image_is_refused() {
228        let image = b"the firmware, arriving in pieces";
229        let manifest = manifest_for(image);
230        let mut altered = *image;
231        altered[3] ^= 0x01;
232        assert_eq!(run(&manifest, &altered), Err(Refusal::Digest));
233    }
234
235    #[test]
236    fn a_short_image_is_refused() {
237        let image = b"the firmware, arriving in pieces";
238        let manifest = manifest_for(image);
239        assert_eq!(
240            run(&manifest, &image[..image.len() - 1]),
241            Err(Refusal::Size)
242        );
243    }
244
245    #[test]
246    fn an_oversized_image_is_stopped_while_it_arrives() {
247        let image = b"short";
248        let manifest = manifest_for(image);
249        let mut verifier = ImageVerifier::new(&manifest);
250        // The refusal lands on the chunk that crosses the declared length, not at
251        // the end, so nothing keeps writing past the slot.
252        assert!(verifier.update(image).is_ok());
253        assert_eq!(verifier.update(b"more"), Err(Refusal::Size));
254    }
255
256    #[test]
257    fn reordered_chunks_are_refused() {
258        let image = b"order matters to a hash";
259        let manifest = manifest_for(image);
260        let mut verifier = ImageVerifier::new(&manifest);
261        verifier.update(&image[10..]).expect("update");
262        verifier.update(&image[..10]).expect("update");
263        assert_eq!(verifier.finish(), Err(Refusal::Digest));
264    }
265}