Skip to main content

rtb_update/
verify.rs

1//! Cryptographic verification: minisign signatures and SHA-256
2//! checksums.
3//!
4//! # Signature format
5//!
6//! One format is supported: minisign's **prehashed `ED`** variant,
7//! `ed25519(BLAKE2b-512(<file>))`. Legacy pure `Ed` and bare 64-byte
8//! signatures are not accepted.
9//!
10//! That is deliberate, and it is what lets one published file serve
11//! both consumers of a release. cargo-binstall **requires** the
12//! prehashed variant and rejects legacy outright, so accepting only
13//! `ED` means what `rtb-update` trusts is exactly what cargo-binstall
14//! trusts. The prehash is also what allows an HSM-held signing key to
15//! sign an artefact of any size — the signer only ever sees a 64-byte
16//! digest, well inside the AWS KMS 4096-byte message cap.
17//!
18//! # Verification is delegated, not reimplemented
19//!
20//! Parsing and checking are done by [`minisign_verify`] — the same
21//! crate cargo-binstall uses — rather than by hand. Two independent
22//! implementations of one check is how the two consumers would
23//! silently drift apart; sharing the implementation makes that
24//! impossible. The crate is zero-dependency (it vendors its own
25//! `BLAKE2b`), so this costs nothing in tree weight.
26//!
27//! It verifies more than the artefact signature: the algorithm tag,
28//! the key id, the signature itself, **and** the global signature over
29//! `signature ‖ trusted_comment`. The trusted comment therefore cannot
30//! be altered without detection — which matters, because producers
31//! record the signing project in it.
32//!
33//! # Public key policy
34//!
35//! `ToolMetadata::update_public_keys` holds minisign public keys as
36//! base64 strings — the same value pinned as `pubkey` in a crate's
37//! `[package.metadata.binstall.signing]` table. Any one verifying is
38//! accepted, so a binary shipped trusting `{old, new}` spans a key
39//! rotation without a dual-signing window.
40
41use minisign_verify::{PublicKey, Signature};
42use sha2::{Digest, Sha256};
43
44use crate::error::UpdateError;
45
46/// Verify `asset_bytes` against the minisign signature `sig_bytes`
47/// under any key in `trusted_keys`. Returns `Ok` as soon as one key
48/// verifies.
49///
50/// `trusted_keys` are base64 minisign public keys. Entries that fail
51/// to parse are skipped so one malformed key cannot disable a trust
52/// set that also holds good ones — but if *none* parses, that is
53/// reported as [`UpdateError::MalformedPublicKey`] rather than a
54/// signature failure, because the fault is in the binary's own trust
55/// set and not in the download.
56///
57/// # Errors
58///
59/// - [`UpdateError::NoPublicKey`] if `trusted_keys` is empty.
60/// - [`UpdateError::MalformedPublicKey`] if no entry parses.
61/// - [`UpdateError::BadSignature`] if the signature file is malformed,
62///   carries the legacy `Ed` algorithm, names a key id no trusted key
63///   matches, or simply does not verify.
64pub fn minisign(
65    asset_filename: &str,
66    asset_bytes: &[u8],
67    sig_bytes: &[u8],
68    trusted_keys: &[String],
69) -> crate::error::Result<()> {
70    if trusted_keys.is_empty() {
71        return Err(UpdateError::NoPublicKey);
72    }
73
74    let bad = || UpdateError::BadSignature { asset: asset_filename.to_string() };
75
76    let sig_text = std::str::from_utf8(sig_bytes).map_err(|_| bad())?;
77    let signature = Signature::decode(sig_text).map_err(|_| bad())?;
78
79    let mut any_key_parsed = false;
80    for key_b64 in trusted_keys {
81        let Ok(public_key) = PublicKey::from_base64(key_b64.trim()) else {
82            continue;
83        };
84        any_key_parsed = true;
85
86        // allow_legacy = false — prehashed "ED" only, matching
87        // cargo-binstall. A legacy "Ed" signature is refused here even
88        // though the key could verify it.
89        if public_key.verify(asset_bytes, &signature, false).is_ok() {
90            return Ok(());
91        }
92    }
93
94    if !any_key_parsed {
95        return Err(UpdateError::MalformedPublicKey);
96    }
97
98    Err(bad())
99}
100
101/// Compute the SHA-256 of `bytes`, lower-case hex-encoded.
102#[must_use]
103pub fn sha256_hex(bytes: &[u8]) -> String {
104    let digest = Sha256::digest(bytes);
105    let mut out = String::with_capacity(digest.len() * 2);
106    for byte in digest {
107        use std::fmt::Write as _;
108        let _ = write!(out, "{byte:02x}");
109    }
110    out
111}
112
113/// Verify `asset_bytes` against a checksums-file body. The body is in
114/// the `sha256sum` format — one `"<hex>  <filename>"` per line.
115/// Matches by the `asset_filename`'s basename.
116///
117/// # Errors
118///
119/// [`UpdateError::BadChecksum`] when the asset's hash doesn't appear
120/// or doesn't match.
121pub fn checksums(
122    asset_filename: &str,
123    asset_bytes: &[u8],
124    checksums_file: &str,
125) -> crate::error::Result<()> {
126    let actual = sha256_hex(asset_bytes);
127    let needle = std::path::Path::new(asset_filename)
128        .file_name()
129        .and_then(|n| n.to_str())
130        .unwrap_or(asset_filename);
131    for line in checksums_file.lines() {
132        let line = line.trim();
133        if line.is_empty() || line.starts_with('#') {
134            continue;
135        }
136        // `hex<whitespace><filename>` — filename may start with `*`
137        // for binary mode. Strip that.
138        let mut parts = line.splitn(2, char::is_whitespace);
139        let Some(hex) = parts.next() else { continue };
140        let Some(file) = parts.next() else { continue };
141        let file = file.trim_start().trim_start_matches('*').trim();
142        if file == needle {
143            return if hex.eq_ignore_ascii_case(&actual) {
144                Ok(())
145            } else {
146                Err(UpdateError::BadChecksum { asset: asset_filename.to_string() })
147            };
148        }
149    }
150    Err(UpdateError::BadChecksum { asset: asset_filename.to_string() })
151}