rtb_update/verify.rs
1//! Cryptographic verification: Ed25519 detached signatures and
2//! SHA-256 checksums.
3//!
4//! # Signature formats
5//!
6//! Two on-wire formats are supported, distinguished by the signature
7//! file's extension:
8//!
9//! - **`.sig`** — 64 raw bytes, the Ed25519 signature itself.
10//! - **`.minisig`** — minisign's text format. Two base64-encoded lines
11//! after the `untrusted comment:` and `trusted comment:` headers;
12//! the first decodes to the Ed25519 key-id + signature (74 bytes),
13//! the second is the per-signature trust comment (not used).
14//! Only the "Ed" (pure Ed25519) algorithm is supported — the "ED"
15//! (prehashed `BLAKE2b`) variant is rejected with a clear error.
16//!
17//! # Public key policy
18//!
19//! `ToolMetadata::update_public_keys` is a `Vec<[u8; 32]>` — any one
20//! of the keys verifying is accepted. This enables key rotation
21//! without breaking already-deployed binaries.
22
23use base64::Engine as _;
24use ed25519_dalek::{Signature, Verifier, VerifyingKey};
25use sha2::{Digest, Sha256};
26
27use crate::error::UpdateError;
28
29/// Verify `asset_bytes` against the detached-signature `sig_bytes`
30/// under any key in `trusted_keys`. Returns `Ok` iff at least one key
31/// verifies; otherwise [`UpdateError::BadSignature`].
32///
33/// `sig_filename` determines the format:
34/// - anything ending in `.minisig` is parsed as minisign,
35/// - everything else is treated as a raw 64-byte Ed25519 signature.
36///
37/// # Errors
38///
39/// [`UpdateError::BadSignature`] on any of: malformed signature file,
40/// unsupported minisign algorithm, or no key verifying.
41pub fn ed25519(
42 asset_filename: &str,
43 sig_filename: &str,
44 asset_bytes: &[u8],
45 sig_bytes: &[u8],
46 trusted_keys: &[[u8; 32]],
47) -> crate::error::Result<()> {
48 if trusted_keys.is_empty() {
49 return Err(UpdateError::NoPublicKey);
50 }
51
52 let sig_raw = if sig_filename.ends_with(".minisig") {
53 parse_minisign(sig_bytes)
54 .ok_or_else(|| UpdateError::BadSignature { asset: asset_filename.to_string() })?
55 } else if sig_bytes.len() == 64 {
56 let mut out = [0u8; 64];
57 out.copy_from_slice(sig_bytes);
58 out
59 } else {
60 return Err(UpdateError::BadSignature { asset: asset_filename.to_string() });
61 };
62 let sig = Signature::from_bytes(&sig_raw);
63
64 for key_bytes in trusted_keys {
65 let Ok(vk) = VerifyingKey::from_bytes(key_bytes) else {
66 continue;
67 };
68 if vk.verify(asset_bytes, &sig).is_ok() {
69 return Ok(());
70 }
71 }
72 Err(UpdateError::BadSignature { asset: asset_filename.to_string() })
73}
74
75/// Parse a minisign `.minisig` body into the raw 64-byte Ed25519
76/// signature, ignoring the 2-byte algorithm id and 8-byte key id.
77/// Returns `None` on malformed input or non-"Ed" algorithm.
78fn parse_minisign(bytes: &[u8]) -> Option<[u8; 64]> {
79 let text = std::str::from_utf8(bytes).ok()?;
80 // Header lines are prefixed with `untrusted comment:` and
81 // `trusted comment:`. We take the first base64 blob that isn't a
82 // comment.
83 for line in text.lines() {
84 let l = line.trim();
85 if l.is_empty() || l.starts_with("untrusted") || l.starts_with("trusted") {
86 continue;
87 }
88 let decoded = base64::engine::general_purpose::STANDARD.decode(l).ok()?;
89 // Format: [algo:2][key_id:8][signature:64] = 74 bytes total.
90 if decoded.len() != 74 {
91 return None;
92 }
93 // Only "Ed" (0x45 0x64) — pure Ed25519 — is supported.
94 if decoded[0..2] != *b"Ed" {
95 return None;
96 }
97 let mut sig = [0u8; 64];
98 sig.copy_from_slice(&decoded[10..74]);
99 return Some(sig);
100 }
101 None
102}
103
104/// Compute the SHA-256 of `bytes`, lower-case hex-encoded.
105#[must_use]
106pub fn sha256_hex(bytes: &[u8]) -> String {
107 let digest = Sha256::digest(bytes);
108 let mut out = String::with_capacity(digest.len() * 2);
109 for byte in digest {
110 use std::fmt::Write as _;
111 let _ = write!(out, "{byte:02x}");
112 }
113 out
114}
115
116/// Verify `asset_bytes` against a checksums-file body. The body is in
117/// the `sha256sum` format — one `"<hex> <filename>"` per line.
118/// Matches by the `asset_filename`'s basename.
119///
120/// # Errors
121///
122/// [`UpdateError::BadChecksum`] when the asset's hash doesn't appear
123/// or doesn't match.
124pub fn checksums(
125 asset_filename: &str,
126 asset_bytes: &[u8],
127 checksums_file: &str,
128) -> crate::error::Result<()> {
129 let actual = sha256_hex(asset_bytes);
130 let needle = std::path::Path::new(asset_filename)
131 .file_name()
132 .and_then(|n| n.to_str())
133 .unwrap_or(asset_filename);
134 for line in checksums_file.lines() {
135 let line = line.trim();
136 if line.is_empty() || line.starts_with('#') {
137 continue;
138 }
139 // `hex<whitespace><filename>` — filename may start with `*`
140 // for binary mode. Strip that.
141 let mut parts = line.splitn(2, char::is_whitespace);
142 let Some(hex) = parts.next() else { continue };
143 let Some(file) = parts.next() else { continue };
144 let file = file.trim_start().trim_start_matches('*').trim();
145 if file == needle {
146 return if hex.eq_ignore_ascii_case(&actual) {
147 Ok(())
148 } else {
149 Err(UpdateError::BadChecksum { asset: asset_filename.to_string() })
150 };
151 }
152 }
153 Err(UpdateError::BadChecksum { asset: asset_filename.to_string() })
154}