Skip to main content

npm_utils/
integrity.rs

1//! Subresource-Integrity verification of downloaded tarballs.
2//!
3//! npm pins each tarball's `sha512-<base64>` digest — in a `package-lock.json` and in the
4//! registry's `dist.integrity`. [`verify`] checks the downloaded bytes against it before they
5//! are trusted, exactly as `npm install` / `npm ci` do. An integrity string with no sha512
6//! component is an error: we never install unverified.
7
8use base64::Engine;
9use sha2::{Digest, Sha512};
10
11/// Verify `bytes` against a Subresource-Integrity string (`sha512-<base64>`, possibly several
12/// space-separated algorithms — we require and check the sha512 one). `name` is for messages.
13pub fn verify(
14    name: &str,
15    bytes: &[u8],
16    integrity: &str,
17) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
18    let expected_b64 = integrity
19        .split_whitespace()
20        .find_map(|token| token.strip_prefix("sha512-"))
21        .ok_or_else(|| format!("package `{name}`: no sha512 integrity to verify against"))?;
22    // Compare the raw 64 digest bytes, not the base64 text.
23    // Decoding the expected SRI makes the check independent of base64 padding or any
24    // non-canonical encoding in the integrity string.
25    let expected = base64::engine::general_purpose::STANDARD
26        .decode(expected_b64)
27        .map_err(|e| format!("package `{name}`: malformed sha512 integrity: {e}"))?;
28    if expected.as_slice() != Sha512::digest(bytes).as_slice() {
29        return Err(format!(
30            "package `{name}`: integrity mismatch — the downloaded tarball does not match \
31             the expected sha512"
32        )
33        .into());
34    }
35    Ok(())
36}
37
38#[cfg(test)]
39mod tests {
40    use super::*;
41
42    #[test]
43    fn verify_checks_sha512_and_rejects_tampering() {
44        let bytes = b"a downloaded tarball's bytes";
45        let good = format!(
46            "sha512-{}",
47            base64::engine::general_purpose::STANDARD.encode(Sha512::digest(bytes))
48        );
49        verify("p", bytes, &good).expect("matching sha512 passes");
50
51        let mut tampered = bytes.to_vec();
52        tampered[0] ^= 0xff;
53        assert!(verify("p", &tampered, &good).is_err(), "flipped byte fails");
54
55        // An integrity string with no sha512 component is rejected (npm-ci-strict).
56        assert!(verify("p", bytes, "sha1-deadbeef").is_err());
57    }
58
59    #[test]
60    fn verify_rejects_malformed_base64() {
61        // A sha512- token whose payload isn't valid base64 is a hard error, not a silent mismatch.
62        assert!(verify("p", b"x", "sha512-@@@@").is_err());
63    }
64
65    #[test]
66    fn verify_finds_sha512_among_algorithms_and_tolerates_whitespace() {
67        // An SRI string may list several space-separated algorithms; we find and check the sha512 one.
68        let bytes = b"multi-algorithm payload";
69        let b64 = base64::engine::general_purpose::STANDARD.encode(Sha512::digest(bytes));
70        let integrity = format!("  sha1-deadbeef   sha512-{b64}  ");
71        verify("p", bytes, &integrity).expect("sha512 is found among the listed algorithms");
72    }
73
74    #[test]
75    fn verify_rejects_a_short_digest() {
76        // Valid base64, but it decodes to fewer than 64 bytes: the raw-slice compare rejects it
77        // on length alone, so a truncated SRI can never match a full sha512 digest.
78        let short = base64::engine::general_purpose::STANDARD.encode(b"only nine");
79        let integrity = format!("sha512-{short}");
80        assert!(
81            verify("p", b"a downloaded tarball's bytes", &integrity).is_err(),
82            "a sub-64-byte digest cannot match"
83        );
84    }
85
86    #[test]
87    fn verify_accepts_exactly_the_pinned_bytes_and_nothing_else() {
88        // For several payloads the canonical SRI verifies, and flipping any single byte of the
89        // data fails: the check accepts exactly the pinned bytes, never a near-miss.
90        for payload in [b"".as_slice(), b"x", b"a slightly longer tarball payload"] {
91            let good = format!(
92                "sha512-{}",
93                base64::engine::general_purpose::STANDARD.encode(Sha512::digest(payload))
94            );
95            verify("p", payload, &good).expect("the exact payload verifies");
96            for i in 0..payload.len() {
97                let mut tampered = payload.to_vec();
98                tampered[i] ^= 0xff;
99                assert!(
100                    verify("p", &tampered, &good).is_err(),
101                    "flipping byte {i} must fail"
102                );
103            }
104        }
105    }
106}