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::{ImageVerifier, Manifest, PayloadFormat, STRUCTURE_VERSION};
77/// use sha2::{Digest, Sha256};
78///
79/// let image = b"firmware bytes";
80/// let manifest = Manifest {
81///     structure_version: STRUCTURE_VERSION,
82///     sequence: 1,
83///     vendor_id: [0; 16],
84///     class_id: [0; 16],
85///     format: PayloadFormat::Raw,
86///     storage: 0,
87///     digest: Sha256::digest(image).into(),
88///     size: image.len() as u32,
89///     expires: 0,
90/// };
91///
92/// let mut verifier = ImageVerifier::new(&manifest);
93/// for chunk in image.chunks(4) {
94///     verifier.update(chunk).unwrap();
95/// }
96/// assert!(verifier.finish().is_ok());
97/// ```
98pub struct ImageVerifier {
99    hasher: Sha256,
100    expected_digest: [u8; DIGEST_LEN],
101    expected_size: u32,
102    seen: u64,
103}
104
105impl ImageVerifier {
106    /// Starts verifying an image against `manifest`.
107    ///
108    /// # Arguments
109    ///
110    /// * `manifest` - the manifest whose digest and size the image must match.
111    ///
112    /// # Returns
113    ///
114    /// A verifier awaiting the image.
115    pub fn new(manifest: &Manifest) -> Self {
116        Self {
117            hasher: Sha256::new(),
118            expected_digest: manifest.digest,
119            expected_size: manifest.size,
120            seen: 0,
121        }
122    }
123
124    /// Folds the next chunk of the image in.
125    ///
126    /// # Arguments
127    ///
128    /// * `chunk` - the next bytes of the image, in order.
129    ///
130    /// # Returns
131    ///
132    /// `Ok(())` once the chunk is hashed.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`Refusal::Size`] as soon as more bytes arrive than the manifest
137    /// declared, so an oversized payload is stopped while it is arriving rather
138    /// than after it has filled the slot.
139    pub fn update(&mut self, chunk: &[u8]) -> Result<()> {
140        self.seen += chunk.len() as u64;
141        if self.seen > u64::from(self.expected_size) {
142            return Err(Refusal::Size);
143        }
144        self.hasher.update(chunk);
145        Ok(())
146    }
147
148    /// Finishes the check and reports whether the image is the one described.
149    ///
150    /// # Returns
151    ///
152    /// A [`Verified`] receipt when the image is exactly the length and content
153    /// the manifest committed to.
154    ///
155    /// # Errors
156    ///
157    /// Returns [`Refusal::Size`] if fewer bytes arrived than declared, or
158    /// [`Refusal::Digest`] if the content does not hash to the manifest's digest.
159    pub fn finish(self) -> Result<Verified> {
160        if self.seen != u64::from(self.expected_size) {
161            return Err(Refusal::Size);
162        }
163
164        let digest: [u8; DIGEST_LEN] = self.hasher.finalize().into();
165        // A wrong digest and a right one take the same work to reject here, but the
166        // comparison is over a hash the attacker cannot steer, so an early return
167        // leaks nothing worth having.
168        if digest != self.expected_digest {
169            return Err(Refusal::Digest);
170        }
171
172        Ok(Verified {
173            size: self.expected_size,
174            digest,
175        })
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::manifest::{PayloadFormat, STRUCTURE_VERSION};
183
184    /// A manifest describing `image` exactly.
185    fn manifest_for(image: &[u8]) -> Manifest {
186        Manifest {
187            structure_version: STRUCTURE_VERSION,
188            sequence: 1,
189            vendor_id: [0; 16],
190            class_id: [0; 16],
191            format: PayloadFormat::Raw,
192            storage: 0,
193            digest: Sha256::digest(image).into(),
194            size: image.len() as u32,
195            expires: 0,
196        }
197    }
198
199    /// Feeds an image through a verifier in small chunks.
200    fn run(manifest: &Manifest, image: &[u8]) -> Result<Verified> {
201        let mut verifier = ImageVerifier::new(manifest);
202        for chunk in image.chunks(7) {
203            verifier.update(chunk)?;
204        }
205        verifier.finish()
206    }
207
208    #[test]
209    fn the_described_image_verifies() {
210        let image = b"the firmware, arriving in pieces";
211        let manifest = manifest_for(image);
212        let verified = run(&manifest, image).expect("verify");
213        assert_eq!(verified.size(), image.len() as u32);
214        assert_eq!(verified.digest(), manifest.digest);
215    }
216
217    #[test]
218    fn an_empty_image_verifies_when_that_is_what_was_described() {
219        let manifest = manifest_for(b"");
220        assert!(run(&manifest, b"").is_ok());
221    }
222
223    #[test]
224    fn a_tampered_image_is_refused() {
225        let image = b"the firmware, arriving in pieces";
226        let manifest = manifest_for(image);
227        let mut altered = *image;
228        altered[3] ^= 0x01;
229        assert_eq!(run(&manifest, &altered), Err(Refusal::Digest));
230    }
231
232    #[test]
233    fn a_short_image_is_refused() {
234        let image = b"the firmware, arriving in pieces";
235        let manifest = manifest_for(image);
236        assert_eq!(
237            run(&manifest, &image[..image.len() - 1]),
238            Err(Refusal::Size)
239        );
240    }
241
242    #[test]
243    fn an_oversized_image_is_stopped_while_it_arrives() {
244        let image = b"short";
245        let manifest = manifest_for(image);
246        let mut verifier = ImageVerifier::new(&manifest);
247        // The refusal lands on the chunk that crosses the declared length, not at
248        // the end, so nothing keeps writing past the slot.
249        assert!(verifier.update(image).is_ok());
250        assert_eq!(verifier.update(b"more"), Err(Refusal::Size));
251    }
252
253    #[test]
254    fn reordered_chunks_are_refused() {
255        let image = b"order matters to a hash";
256        let manifest = manifest_for(image);
257        let mut verifier = ImageVerifier::new(&manifest);
258        verifier.update(&image[10..]).expect("update");
259        verifier.update(&image[..10]).expect("update");
260        assert_eq!(verifier.finish(), Err(Refusal::Digest));
261    }
262}