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}