Fix audit findings: bakery index signing, artifact checksums, stale theme docs

- Add minisign-based signing/verification for the bakery index:
  scripts/gen-index.sh signs index.json (MINISIGN_SEC_KEY env var, dormant
  no-op with a loud warning until a key is provisioned); bakery/src/manifest.rs
  fetches index.json.minisig and verifies it with minisign-verify against a
  hardcoded PUBKEY before parsing/caching, and re-verifies the cached copy
  on every load (falls back to one re-fetch if the cache predates signing
  or fails verification; a fresh fetch that fails verification is a hard
  error).
- Close the previously-unchecksummed config-example and systemd-unit
  downloads in bakery/src/install.rs (scaffold_config, install_service):
  index.json now carries `sha256`/`example_sha256` for these artifacts
  (computed in gen-index.sh), verified via the same download::verify_sha256
  used for binaries. Downloads without a matching sha256 in the index are
  refused rather than installed unverified.
- scripts/get.sh now verifies the bakery release binary itself against a
  pinned minisign public key before installing it (falls back to the
  existing sha256-only check with a loud warning if no .minisig is
  published yet or minisign isn't installed; a present-but-invalid
  signature is a hard failure).
- Add dormant "sign release binary" steps to the bakery and bread-theme
  release workflows (.github/workflows/release.yml,
  .forgejo/workflows/release-bread-theme.yml), gated on secrets that are
  not yet configured — binaries ship unsigned exactly as before until the
  owner wires up the secret.
- .gitignore: add *.minisign-sec / minisign.key so the signing key can
  never be committed by accident.
- bread-theme: fix stale docs describing a "Catppuccin Mocha fallback"
  (BREAD_DESIGN_SYSTEM.md, README.md, Cargo.toml/bakery.toml/registry
  descriptions) — the actual implementation (palette.rs) uses a fixed BOS
  dark base with only accent colors from pywal.
- bread-theme: fix the legacy css_vars() path, which had its own
  hand-written @define-color block that predated the `accent` and computed
  `on-*` ink colors used by the rest of the stylesheet — any caller whose
  CSS referenced those names against css_vars()'s output would hit
  undefined colors (the illegible-text bug). css_vars() now delegates to
  the same define_colors() the full stylesheet uses, so the two can't
  drift apart again.
This commit is contained in:
Breadway 2026-07-17 03:37:51 +08:00
parent fa0597f482
commit 394a252f9e
18 changed files with 472 additions and 84 deletions

View file

@ -32,7 +32,12 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> {
Ok(())
}
fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
/// Verify that `bytes` hashes to `expected_hex` under SHA-256.
///
/// Shared by every artifact download path — binaries (via
/// [`fetch_and_place`]), and config-example / systemd-unit downloads in
/// `install.rs` — so all downloaded artifacts get the same integrity check.
pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
let mut hasher = Sha256::new();
hasher.update(bytes);
let actual = hex::encode(hasher.finalize());

View file

@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::download::fetch_and_place;
use crate::download::{fetch_and_place, verify_sha256};
use crate::manifest::{fetch_binary, Package, Service};
use crate::state::{InstalledPackage, State};
@ -119,11 +119,28 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu
if !dest.exists() {
if let Some((primary, fallback)) = pkg.artifact_urls(example) {
match fetch_binary(&primary, &fallback) {
Ok(bytes) => {
std::fs::write(&dest, &bytes)
.with_context(|| format!("writing {}", dest.display()))?;
println!(" installed example config at {}", dest.display());
}
Ok(bytes) => match &cfg.example_sha256 {
Some(expected) => match verify_sha256(&bytes, expected) {
Ok(()) => {
std::fs::write(&dest, &bytes)
.with_context(|| format!("writing {}", dest.display()))?;
println!(" installed example config at {}", dest.display());
}
Err(e) => {
eprintln!(
" warning: checksum mismatch for example config {example}: {e} — not installed"
);
println!(" config dir created at {}", dir.display());
}
},
None => {
eprintln!(
" warning: index.json has no sha256 for example config \
{example} refusing to install an unverified download"
);
println!(" config dir created at {}", dir.display());
}
},
Err(e) => {
eprintln!(" warning: could not download example config {example}: {e}");
println!(" config dir created at {}", dir.display());
@ -151,11 +168,19 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
if !unit_path.exists() {
if let Some((primary, fallback)) = pkg.artifact_urls(&svc.unit) {
match fetch_binary(&primary, &fallback) {
Ok(bytes) => {
std::fs::write(&unit_path, &bytes)
.with_context(|| format!("writing {}", unit_path.display()))?;
println!(" downloaded unit {}", unit_path.display());
}
Ok(bytes) => match verify_sha256(&bytes, &svc.sha256) {
Ok(()) => {
std::fs::write(&unit_path, &bytes)
.with_context(|| format!("writing {}", unit_path.display()))?;
println!(" downloaded unit {}", unit_path.display());
}
Err(e) => {
eprintln!(
" warning: checksum mismatch for unit {}: {e} — not installed",
svc.unit
);
}
},
Err(e) => {
eprintln!(" warning: could not download {}: {e}", svc.unit);
}

View file

@ -1,11 +1,45 @@
use anyhow::{bail, Context, Result};
use minisign_verify::{PublicKey, Signature};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
const PRIMARY_URL: &str = "https://dl.breadway.dev/index.json";
const SIG_URL: &str = "https://dl.breadway.dev/index.json.minisig";
const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600);
/// The bakery index-signing public key.
///
/// The matching secret key is used offline (never on this machine, never in
/// this repo) to sign `index.json` with `minisign` as part of publishing a
/// new index — see `scripts/gen-index.sh`. Every fetch of `index.json`, and
/// every load of the on-disk cache, must verify against this key before the
/// bytes are trusted or parsed. This is the single control point: the
/// per-artifact `sha256` fields and `post_install` hook strings all live
/// inside `index.json` itself, so a valid signature transitively covers them.
const PUBKEY: &str = "RWRh2Zr5SUinvVFCtD7S7HwGjfrye6j31Xq2mYXRdkGFDWe3yHF7W11K";
/// Verify `bytes` against `sig_text` (the contents of an `index.json.minisig`
/// file) using the pinned [`PUBKEY`]. Returns an error on any failure —
/// missing/malformed signature, wrong key, or a hash mismatch.
fn verify_index_signature(bytes: &[u8], sig_text: &str) -> Result<()> {
verify_against_key(bytes, sig_text, PUBKEY)
}
/// Verify `bytes` against a minisign `sig_text` using an arbitrary base64
/// public key. Split out from [`verify_index_signature`] purely so tests can
/// exercise the verification logic with a throwaway keypair instead of the
/// real production key.
fn verify_against_key(bytes: &[u8], sig_text: &str, pubkey_b64: &str) -> Result<()> {
let public_key =
PublicKey::from_base64(pubkey_b64).context("public key is malformed")?;
let signature =
Signature::decode(sig_text).context("index.json.minisig is malformed or unreadable")?;
public_key
.verify(bytes, &signature, false)
.context("index.json failed signature verification against the pinned bakery key")
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Binary {
pub name: String,
@ -18,6 +52,10 @@ pub struct Binary {
pub struct Service {
pub unit: String,
pub enable: bool,
/// SHA-256 of the unit file artifact. Required to verify the download in
/// `install::install_service`, same as binaries; `index.json` carries it
/// (and is itself minisign-signed, which is what makes it trustworthy).
pub sha256: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@ -25,6 +63,10 @@ pub struct ConfigScaffold {
pub dir: String,
/// Example config filename, relative to the release artifact directory.
pub example: Option<String>,
/// SHA-256 of the example config artifact, when `example` is set.
/// Verified in `install::scaffold_config` the same way binaries are.
#[serde(default)]
pub example_sha256: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@ -80,15 +122,38 @@ impl Index {
/// Load the manifest, using the on-disk cache when it is fresh enough.
/// Always fetches if `force_refresh` is true.
///
/// Every path — fresh fetch or cached read — verifies the minisign
/// signature over the raw `index.json` bytes before the JSON is parsed or
/// trusted. A signature failure on a freshly fetched index is always a hard
/// error. A signature failure on the *cached* copy is treated as a
/// (possibly tampered, possibly just stale-format) cache and triggers one
/// re-fetch from the network rather than bricking the CLI outright; if the
/// freshly fetched copy also fails to verify, that's a hard error.
pub fn load(force_refresh: bool) -> Result<Index> {
let cache_path = cache_path();
let sig_cache_path = sig_cache_path(&cache_path);
if !force_refresh && cache_is_fresh(&cache_path) {
let text = std::fs::read_to_string(&cache_path).context("reading cached index")?;
return serde_json::from_str(&text).context("parsing cached index");
match read_and_verify_cache(&cache_path, &sig_cache_path) {
Ok(index) => return Ok(index),
Err(err) => {
eprintln!(
" warning: cached index.json failed verification ({err}), re-fetching…"
);
}
}
}
fetch_and_cache(&cache_path)
fetch_and_cache(&cache_path, &sig_cache_path)
}
fn read_and_verify_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result<Index> {
let bytes = std::fs::read(cache_path).context("reading cached index")?;
let sig_text = std::fs::read_to_string(sig_cache_path)
.context("reading cached index.json.minisig (cache predates signing support)")?;
verify_index_signature(&bytes, &sig_text)?;
serde_json::from_slice(&bytes).context("parsing cached index")
}
fn cache_is_fresh(path: &PathBuf) -> bool {
@ -98,13 +163,26 @@ fn cache_is_fresh(path: &PathBuf) -> bool {
.unwrap_or(false)
}
fn fetch_and_cache(cache_path: &PathBuf) -> Result<Index> {
let text = fetch_text(PRIMARY_URL)?;
fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result<Index> {
let bytes = fetch_bytes(PRIMARY_URL)?;
let sig_text = fetch_text(SIG_URL).context(
"fetching index.json.minisig — the index must be signed before it can be trusted",
)?;
verify_index_signature(&bytes, &sig_text)
.context("freshly fetched index.json failed signature verification")?;
if let Some(dir) = cache_path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(cache_path, &text)?;
serde_json::from_str(&text).context("parsing index.json")
std::fs::write(cache_path, &bytes)?;
std::fs::write(sig_cache_path, &sig_text)?;
serde_json::from_slice(&bytes).context("parsing index.json")
}
fn sig_cache_path(cache_path: &Path) -> PathBuf {
let mut name = cache_path.file_name().unwrap_or_default().to_os_string();
name.push(".minisig");
cache_path.with_file_name(name)
}
fn fetch_text(url: &str) -> Result<String> {
@ -151,3 +229,51 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
.context("reading response")?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
// A throwaway test-only minisign keypair, generated solely to produce
// these fixtures (`minisign -G` then `minisign -S`). It has no
// relationship to the real bakery signing key (PUBKEY above) and the
// matching secret key was discarded — these are just fixed vectors to
// exercise the verification code path deterministically.
const TEST_PUBKEY: &str = "RWQTYQi9Fe4trQDQmbb9txWDxzUIPYs57J//A5wG9BHcZXgC8YP0Cf59";
const TEST_DATA: &[u8] = b"{\"hello\":\"world\"}\n";
const TEST_SIG: &str = "untrusted comment: signature from minisign secret key\n\
RUQTYQi9Fe4trXY/WBxk++476WhTqtVd3hlNWQj5h5DF8keP8sEJn22LDG2hloNgJesXt6HsTQs9uktayRVp/HB4XfC6e+rhYAs=\n\
trusted comment: timestamp:1784230084\tfile:test-data.json\thashed\n\
znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+NR/1AA==\n";
#[test]
fn valid_signature_verifies() {
verify_against_key(TEST_DATA, TEST_SIG, TEST_PUBKEY)
.expect("known-good signature must verify");
}
#[test]
fn tampered_bytes_fail_verification() {
let tampered = b"{\"hello\":\"world!\"}\n".to_vec();
assert!(verify_against_key(&tampered, TEST_SIG, TEST_PUBKEY).is_err());
}
#[test]
fn wrong_key_fails_verification() {
// PUBKEY is the real production key — unrelated to the throwaway
// TEST_PUBKEY the fixture was signed with, so it must not verify.
assert!(verify_against_key(TEST_DATA, TEST_SIG, PUBKEY).is_err());
}
#[test]
fn malformed_signature_text_errors_cleanly() {
assert!(verify_against_key(TEST_DATA, "not a real signature", TEST_PUBKEY).is_err());
}
#[test]
fn production_pubkey_constant_is_well_formed() {
// Guards against a future typo/truncation in the hardcoded PUBKEY —
// it must at least parse as a valid minisign public key.
PublicKey::from_base64(PUBKEY).expect("PUBKEY must be a valid minisign public key");
}
}