Skip to main content

osdk_core/pipeline/
verify.rs

1//! Checksum verification for downloaded archives.
2
3use std::io::Read;
4use std::path::Path;
5
6use sha2::{Digest, Sha256, Sha512};
7
8use crate::error::{Error, Result};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum HashAlgo {
12    Sha256,
13    Sha512,
14    Blake3,
15}
16
17pub fn hash_bytes(bytes: &[u8], algo: HashAlgo) -> String {
18    match algo {
19        HashAlgo::Sha256 => hex::encode(Sha256::digest(bytes)),
20        HashAlgo::Sha512 => hex::encode(Sha512::digest(bytes)),
21        HashAlgo::Blake3 => blake3::hash(bytes).to_hex().to_string(),
22    }
23}
24
25/// Compute the hex digest of a file with the given algorithm.
26pub fn hash_file(path: &Path, algo: HashAlgo) -> Result<String> {
27    let mut f = std::fs::File::open(path).map_err(|e| Error::io(path, e))?;
28    let mut buf = [0u8; 64 * 1024];
29    match algo {
30        HashAlgo::Sha256 => {
31            let mut h = Sha256::new();
32            loop {
33                let n = f.read(&mut buf).map_err(|e| Error::io(path, e))?;
34                if n == 0 {
35                    break;
36                }
37                h.update(&buf[..n]);
38            }
39            Ok(hex::encode(h.finalize()))
40        }
41        HashAlgo::Sha512 => {
42            let mut h = Sha512::new();
43            loop {
44                let n = f.read(&mut buf).map_err(|e| Error::io(path, e))?;
45                if n == 0 {
46                    break;
47                }
48                h.update(&buf[..n]);
49            }
50            Ok(hex::encode(h.finalize()))
51        }
52        HashAlgo::Blake3 => {
53            let mut h = blake3::Hasher::new();
54            loop {
55                let n = f.read(&mut buf).map_err(|e| Error::io(path, e))?;
56                if n == 0 {
57                    break;
58                }
59                h.update(&buf[..n]);
60            }
61            Ok(h.finalize().to_hex().to_string())
62        }
63    }
64}
65
66/// Verify `path` matches `expected` (hex) under `algo`. Case-insensitive.
67pub fn verify_file(path: &Path, expected: &str, algo: HashAlgo, name: &str) -> Result<()> {
68    let actual = hash_file(path, algo)?;
69    if actual.eq_ignore_ascii_case(expected.trim()) {
70        Ok(())
71    } else {
72        Err(Error::ChecksumMismatch {
73            name: name.to_string(),
74            expected: expected.to_string(),
75            actual,
76        })
77    }
78}
79
80/// Parse a `SHASUMS256.txt`-style body and return the hash for `filename`.
81/// Each line is `<hex>  <filename>` (two spaces) or `<hex> *<filename>`.
82pub fn find_shasum<'a>(body: &'a str, filename: &str) -> Option<&'a str> {
83    for line in body.lines() {
84        let line = line.trim();
85        if line.is_empty() {
86            continue;
87        }
88        let mut it = line.split_whitespace();
89        let hash = it.next()?;
90        let name = it.next()?;
91        // manifests may list a path; match on the basename too
92        let name = name.trim_start_matches('*');
93        let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
94        if name == filename || base == filename {
95            return Some(hash);
96        }
97    }
98    None
99}
100
101/// Extract the first 64-hex-char sha256 token from a sidecar body (a bare hash,
102/// or `<hex>  <filename>`).
103pub fn parse_sha256_token(body: &str) -> Option<String> {
104    let token = body.split_whitespace().next()?;
105    if token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit()) {
106        Some(token.to_string())
107    } else {
108        None
109    }
110}
111
112/// Parse an npm-style Subresource Integrity string (`sha512-<base64>` or
113/// `sha256-<base64>`, possibly space-separated multiples) into a hex Checksum.
114/// Prefers sha512. Returns None if unparseable.
115pub fn parse_sri(integrity: &str) -> Option<super::Checksum> {
116    use base64::Engine;
117    let mut best: Option<super::Checksum> = None;
118    for token in integrity.split_whitespace() {
119        let (algo_str, b64) = token.split_once('-')?;
120        let algo = match algo_str {
121            "sha512" => HashAlgo::Sha512,
122            "sha256" => HashAlgo::Sha256,
123            _ => continue,
124        };
125        let raw = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
126        let hex = hex::encode(raw);
127        let cs = super::Checksum { algo, hex };
128        // prefer the strongest (sha512 > sha256)
129        match &best {
130            Some(b) if b.algo == HashAlgo::Sha512 => {}
131            _ => best = Some(cs),
132        }
133    }
134    best
135}
136
137/// Best-effort discovery of a sha256 checksum for a GitHub-style release asset.
138///
139/// Given the full asset download URL, tries (in order):
140/// 1. per-asset sidecars: `<url>.sha256`, `<url>.sha256sum`, `<url>.sha256.txt`
141/// 2. a shared manifest in the same directory: `SHASUMS256.txt`, `SHA256SUMS`,
142///    `checksums.txt` — matched by the asset's filename.
143///
144/// Returns `None` if nothing is found (caller proceeds without verification).
145pub async fn discover_asset_checksum(
146    client: &reqwest::Client,
147    asset_url: &str,
148) -> Option<super::Checksum> {
149    // 1. per-asset sidecars
150    for suffix in [".sha256", ".sha256sum", ".sha256.txt"] {
151        let url = format!("{asset_url}{suffix}");
152        if let Ok(body) = crate::http::get_text(client, &url).await {
153            if let Some(hex) = parse_sha256_token(&body) {
154                return Some(super::Checksum {
155                    algo: HashAlgo::Sha256,
156                    hex,
157                });
158            }
159        }
160    }
161    // 2. shared manifest in the same directory
162    let (dir, file) = asset_url.rsplit_once('/')?;
163    for manifest in [
164        "SHASUMS256.txt",
165        "SHA256SUMS",
166        "sha256sums.txt",
167        "checksums.txt",
168    ] {
169        let url = format!("{dir}/{manifest}");
170        if let Ok(body) = crate::http::get_text(client, &url).await {
171            if let Some(hex) = find_shasum(&body, file) {
172                return Some(super::Checksum {
173                    algo: HashAlgo::Sha256,
174                    hex: hex.to_string(),
175                });
176            }
177        }
178    }
179    None
180}
181
182/// Verify a detached minisign signature over `file` using a minisign public key
183/// (the base64 key line, e.g. `RWT...`). Used for artifacts signed with
184/// minisign (osdk's own releases; some upstreams). Returns Ok(()) on a valid
185/// signature.
186pub fn verify_minisign(file: &Path, signature: &str, public_key_b64: &str) -> Result<()> {
187    let bytes = std::fs::read(file).map_err(|e| Error::io(file, e))?;
188    verify_minisign_bytes(&bytes, signature, public_key_b64)
189}
190
191/// Verify a detached minisign signature over in-memory `bytes`.
192pub fn verify_minisign_bytes(bytes: &[u8], signature: &str, public_key_b64: &str) -> Result<()> {
193    let pk = minisign_verify::PublicKey::from_base64(public_key_b64.trim())
194        .map_err(|e| Error::other(format!("invalid minisign public key: {e}")))?;
195    let sig = minisign_verify::Signature::decode(signature)
196        .map_err(|e| Error::other(format!("invalid minisign signature: {e}")))?;
197    // stream=false: verify the whole buffer (prehashed sigs are auto-detected).
198    pk.verify(bytes, &sig, false)
199        .map_err(|e| Error::other(format!("minisign verification failed: {e}")))?;
200    Ok(())
201}
202
203/// A trusted minisign public key for a source's signed checksums manifest.
204pub struct TrustedKey {
205    /// The minisign public key (base64 line, `RW...`).
206    pub public_key: &'static str,
207    /// Sibling signature file name for the manifest (e.g. `SHASUMS256.txt.minisig`).
208    pub manifest: &'static str,
209    pub signature: &'static str,
210}
211
212/// Built-in trusted minisign keys for well-known signed distributions, keyed by
213/// `github:owner/repo`. Verified against real releases.
214pub fn trusted_key(repo_id: &str) -> Option<TrustedKey> {
215    match repo_id {
216        // mise signs SHASUMS256.txt with this key (key id 64113EDF160FDEC2).
217        "github:jdx/mise" => Some(TrustedKey {
218            public_key: "RWTC3g8W3z4RZK3V3qv7fa1QY4JEWyBtqIHW+85QlJpZc5yG+uNYNBSZ",
219            manifest: "SHASUMS256.txt",
220            signature: "SHASUMS256.txt.minisig",
221        }),
222        _ => None,
223    }
224}
225
226/// For a signed distribution: fetch the checksums manifest + its `.minisig`,
227/// verify the signature with the trusted key, and (only if valid) return the
228/// asset's sha256 from the verified manifest. `dir_url` is the release download
229/// directory (where the manifest lives); `filename` is the asset basename.
230///
231/// Returns Ok(Some(checksum)) on a verified match, Ok(None) if this repo has no
232/// trusted key / no matching entry, and Err if the signature is INVALID (a hard
233/// failure — never silently trust an unsigned/forged manifest here).
234pub async fn signed_manifest_checksum(
235    client: &reqwest::Client,
236    repo_id: &str,
237    dir_url: &str,
238    filename: &str,
239) -> Result<Option<super::Checksum>> {
240    let key = match trusted_key(repo_id) {
241        Some(k) => k,
242        None => return Ok(None),
243    };
244    let dir = dir_url.trim_end_matches('/');
245    let manifest_url = format!("{dir}/{}", key.manifest);
246    let sig_url = format!("{dir}/{}", key.signature);
247
248    let manifest = match crate::http::get_text(client, &manifest_url).await {
249        Ok(m) => m,
250        Err(_) => return Ok(None), // manifest not reachable; fall back to other paths
251    };
252    let signature = match crate::http::get_text(client, &sig_url).await {
253        Ok(s) => s,
254        Err(_) => return Ok(None),
255    };
256
257    // Verify the manifest's signature — hard-fail if invalid.
258    verify_minisign_bytes(manifest.as_bytes(), &signature, key.public_key)?;
259
260    // Signature valid: trust hashes from this manifest.
261    Ok(find_shasum(&manifest, filename).map(|hex| super::Checksum {
262        algo: HashAlgo::Sha256,
263        hex: hex.to_string(),
264    }))
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use std::io::Write;
271
272    #[test]
273    fn sha256_known_vector() {
274        let td = tempfile::tempdir().unwrap();
275        let p = td.path().join("f");
276        let mut f = std::fs::File::create(&p).unwrap();
277        f.write_all(b"abc").unwrap();
278        // sha256("abc")
279        let expected = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
280        assert!(verify_file(&p, expected, HashAlgo::Sha256, "f").is_ok());
281        assert!(verify_file(&p, "deadbeef", HashAlgo::Sha256, "f").is_err());
282    }
283
284    #[test]
285    fn parse_shasums() {
286        let body = "aaaa  node-v20-linux-x64.tar.gz\nbbbb  node-v20-linux-x64.tar.xz\n";
287        assert_eq!(find_shasum(body, "node-v20-linux-x64.tar.xz"), Some("bbbb"));
288        assert_eq!(find_shasum(body, "missing"), None);
289    }
290
291    #[test]
292    fn shasums_matches_basename_in_path() {
293        // manifests sometimes list a path; match on the basename too
294        let body = "cccc  ./dist/bun-linux-x64.zip\n";
295        assert_eq!(find_shasum(body, "bun-linux-x64.zip"), Some("cccc"));
296    }
297
298    #[test]
299    fn sidecar_token_parsing() {
300        let hex = "b".repeat(64);
301        assert_eq!(parse_sha256_token(&hex).as_deref(), Some(hex.as_str()));
302        // `<hex>  <filename>` form
303        let body = format!("{hex}  deno-x86_64-unknown-linux-gnu.zip\n");
304        assert_eq!(parse_sha256_token(&body).as_deref(), Some(hex.as_str()));
305        // too short / non-hex -> None
306        assert_eq!(parse_sha256_token("nothex"), None);
307        assert_eq!(parse_sha256_token(""), None);
308    }
309
310    #[test]
311    fn sri_parsing_prefers_sha512() {
312        use base64::Engine;
313        // sha256 of "abc"
314        let sha256_hex = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
315        let sha256_b64 =
316            base64::engine::general_purpose::STANDARD.encode(hex::decode(sha256_hex).unwrap());
317        let cs = parse_sri(&format!("sha256-{sha256_b64}")).unwrap();
318        assert_eq!(cs.algo, HashAlgo::Sha256);
319        assert_eq!(cs.hex, sha256_hex);
320
321        // when both present, sha512 wins
322        let sha512_hex = "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a\
323                          2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f";
324        let sha512_b64 =
325            base64::engine::general_purpose::STANDARD.encode(hex::decode(sha512_hex).unwrap());
326        let cs = parse_sri(&format!("sha256-{sha256_b64} sha512-{sha512_b64}")).unwrap();
327        assert_eq!(cs.algo, HashAlgo::Sha512);
328        assert_eq!(cs.hex, sha512_hex);
329
330        assert!(parse_sri("md5-xxxx").is_none());
331    }
332
333    #[test]
334    fn minisign_rejects_bad_inputs() {
335        let td = tempfile::tempdir().unwrap();
336        let f = td.path().join("artifact");
337        std::fs::write(&f, b"payload").unwrap();
338        // Invalid public key.
339        assert!(verify_minisign(&f, "untrusted comment\nRWQf6L...", "not-a-key").is_err());
340        // Well-formed-looking but invalid signature against a syntactically
341        // valid-length key should also error (never panics).
342        let fake_key = "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3";
343        assert!(verify_minisign(&f, "garbage", fake_key).is_err());
344    }
345
346    // Real mise minisign key + a real signature over its SHASUMS256.txt.
347    // (The positive verification against the exact 3520-byte manifest was
348    // confirmed live; here we assert the trusted key is wired and that verifying
349    // tampered bytes with the real signature FAILS — never silently passes.)
350    const MISE_KEY: &str = "RWTC3g8W3z4RZK3V3qv7fa1QY4JEWyBtqIHW+85QlJpZc5yG+uNYNBSZ";
351    const MISE_SIG: &str = "untrusted comment: signature from minisign secret key\n\
352        RUTC3g8W3z4RZPN3yrytMMxcrYyruSFMJw/fd1BsY9CWTb06OvLLpbNdRmdTfO9yqMBy4TcBu4ZiUr6e+WLWViNnRyT4J0pUAA4=\n\
353        trusted comment: timestamp:1735607189\tfile:SHASUMS256.txt\thashed\n\
354        rqyHRI2HBPZJQLqYOpdcB8g7aKcsOVA9+NY5Gn12aguvRokwIZhwHAg5z+xIvuu2iplosMcbP0lBhrhh+K2LCQ==\n";
355
356    #[test]
357    fn trusted_key_registered_for_mise() {
358        let k = trusted_key("github:jdx/mise").expect("mise key present");
359        assert_eq!(k.public_key, MISE_KEY);
360        assert_eq!(k.manifest, "SHASUMS256.txt");
361        assert!(trusted_key("github:unknown/repo").is_none());
362    }
363
364    #[test]
365    fn real_signature_rejects_tampered_content() {
366        // The signature is over the exact SHASUMS256.txt bytes; any other bytes
367        // must fail verification with the real key + real signature.
368        let tampered = b"this is not the signed manifest";
369        assert!(verify_minisign_bytes(tampered, MISE_SIG, MISE_KEY).is_err());
370        // Key parsing + signature decoding themselves must succeed (proves the
371        // material is well-formed; only the content mismatch causes failure).
372        assert!(minisign_verify::PublicKey::from_base64(MISE_KEY).is_ok());
373        assert!(minisign_verify::Signature::decode(MISE_SIG).is_ok());
374    }
375}