Skip to main content

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}