rtb-update 0.8.0

Self-update subsystem composing on rtb-forge to fetch signed release assets and atomically swap the running binary. Part of the phpboyscout Rust toolkit.
Documentation
//! Cryptographic verification: minisign signatures and SHA-256
//! checksums.
//!
//! # Signature format
//!
//! One format is supported: minisign's **prehashed `ED`** variant,
//! `ed25519(BLAKE2b-512(<file>))`. Legacy pure `Ed` and bare 64-byte
//! signatures are not accepted.
//!
//! That is deliberate, and it is what lets one published file serve
//! both consumers of a release. cargo-binstall **requires** the
//! prehashed variant and rejects legacy outright, so accepting only
//! `ED` means what `rtb-update` trusts is exactly what cargo-binstall
//! trusts. The prehash is also what allows an HSM-held signing key to
//! sign an artefact of any size — the signer only ever sees a 64-byte
//! digest, well inside the AWS KMS 4096-byte message cap.
//!
//! # Verification is delegated, not reimplemented
//!
//! Parsing and checking are done by [`minisign_verify`] — the same
//! crate cargo-binstall uses — rather than by hand. Two independent
//! implementations of one check is how the two consumers would
//! silently drift apart; sharing the implementation makes that
//! impossible. The crate is zero-dependency (it vendors its own
//! `BLAKE2b`), so this costs nothing in tree weight.
//!
//! It verifies more than the artefact signature: the algorithm tag,
//! the key id, the signature itself, **and** the global signature over
//! `signature ‖ trusted_comment`. The trusted comment therefore cannot
//! be altered without detection — which matters, because producers
//! record the signing project in it.
//!
//! # Public key policy
//!
//! `ToolMetadata::update_public_keys` holds minisign public keys as
//! base64 strings — the same value pinned as `pubkey` in a crate's
//! `[package.metadata.binstall.signing]` table. Any one verifying is
//! accepted, so a binary shipped trusting `{old, new}` spans a key
//! rotation without a dual-signing window.

use minisign_verify::{PublicKey, Signature};
use sha2::{Digest, Sha256};

use crate::error::UpdateError;

/// Verify `asset_bytes` against the minisign signature `sig_bytes`
/// under any key in `trusted_keys`. Returns `Ok` as soon as one key
/// verifies.
///
/// `trusted_keys` are base64 minisign public keys. Entries that fail
/// to parse are skipped so one malformed key cannot disable a trust
/// set that also holds good ones — but if *none* parses, that is
/// reported as [`UpdateError::MalformedPublicKey`] rather than a
/// signature failure, because the fault is in the binary's own trust
/// set and not in the download.
///
/// # Errors
///
/// - [`UpdateError::NoPublicKey`] if `trusted_keys` is empty.
/// - [`UpdateError::MalformedPublicKey`] if no entry parses.
/// - [`UpdateError::BadSignature`] if the signature file is malformed,
///   carries the legacy `Ed` algorithm, names a key id no trusted key
///   matches, or simply does not verify.
pub fn minisign(
    asset_filename: &str,
    asset_bytes: &[u8],
    sig_bytes: &[u8],
    trusted_keys: &[String],
) -> crate::error::Result<()> {
    if trusted_keys.is_empty() {
        return Err(UpdateError::NoPublicKey);
    }

    let bad = || UpdateError::BadSignature { asset: asset_filename.to_string() };

    let sig_text = std::str::from_utf8(sig_bytes).map_err(|_| bad())?;
    let signature = Signature::decode(sig_text).map_err(|_| bad())?;

    let mut any_key_parsed = false;
    for key_b64 in trusted_keys {
        let Ok(public_key) = PublicKey::from_base64(key_b64.trim()) else {
            continue;
        };
        any_key_parsed = true;

        // allow_legacy = false — prehashed "ED" only, matching
        // cargo-binstall. A legacy "Ed" signature is refused here even
        // though the key could verify it.
        if public_key.verify(asset_bytes, &signature, false).is_ok() {
            return Ok(());
        }
    }

    if !any_key_parsed {
        return Err(UpdateError::MalformedPublicKey);
    }

    Err(bad())
}

/// Compute the SHA-256 of `bytes`, lower-case hex-encoded.
#[must_use]
pub fn sha256_hex(bytes: &[u8]) -> String {
    let digest = Sha256::digest(bytes);
    let mut out = String::with_capacity(digest.len() * 2);
    for byte in digest {
        use std::fmt::Write as _;
        let _ = write!(out, "{byte:02x}");
    }
    out
}

/// Verify `asset_bytes` against a checksums-file body. The body is in
/// the `sha256sum` format — one `"<hex>  <filename>"` per line.
/// Matches by the `asset_filename`'s basename.
///
/// # Errors
///
/// [`UpdateError::BadChecksum`] when the asset's hash doesn't appear
/// or doesn't match.
pub fn checksums(
    asset_filename: &str,
    asset_bytes: &[u8],
    checksums_file: &str,
) -> crate::error::Result<()> {
    let actual = sha256_hex(asset_bytes);
    let needle = std::path::Path::new(asset_filename)
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or(asset_filename);
    for line in checksums_file.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        // `hex<whitespace><filename>` — filename may start with `*`
        // for binary mode. Strip that.
        let mut parts = line.splitn(2, char::is_whitespace);
        let Some(hex) = parts.next() else { continue };
        let Some(file) = parts.next() else { continue };
        let file = file.trim_start().trim_start_matches('*').trim();
        if file == needle {
            return if hex.eq_ignore_ascii_case(&actual) {
                Ok(())
            } else {
                Err(UpdateError::BadChecksum { asset: asset_filename.to_string() })
            };
        }
    }
    Err(UpdateError::BadChecksum { asset: asset_filename.to_string() })
}