diff --git a/.forgejo/workflows/release-bread-theme.yml b/.forgejo/workflows/release-bread-theme.yml index c5e2426..335f982 100644 --- a/.forgejo/workflows/release-bread-theme.yml +++ b/.forgejo/workflows/release-bread-theme.yml @@ -31,7 +31,31 @@ jobs: cp src/bread-theme/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/bread-theme/latest" + # Signs the bread-theme binary with the shared bakery ecosystem signing + # key (same key that signs index.json — get.sh / manifest.rs pin the + # matching public key). BAKERY_MINISIGN_SEC_KEY_PATH is a *path on this + # runner's disk* (hestia has persistent storage, unlike a fresh + # GitHub-hosted runner), not the key contents — see the handoff note + # in scripts/gen-index.sh. Dormant (binary ships unsigned, as today) + # until that secret is provisioned. + - name: sign release binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/bread-theme/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bread-theme-x86_64" \ + -x "${PKG_DIR}/bread-theme-x86_64.minisig" /dev/null || true - gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem \ - "${PKG_DIR}/bread-theme-x86_64" \ - "${PKG_DIR}/bread-theme-x86_64.sha256" \ - --clobber + ASSETS="${PKG_DIR}/bread-theme-x86_64 ${PKG_DIR}/bread-theme-x86_64.sha256" + [ -f "${PKG_DIR}/bread-theme-x86_64.minisig" ] && ASSETS="${ASSETS} ${PKG_DIR}/bread-theme-x86_64.minisig" + gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem ${ASSETS} --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a977bc2..1f9675b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,8 +36,41 @@ jobs: cp bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "${DL_DIR}/bakery/latest" + # Signs the bakery binary itself with the same minisign key that signs + # index.json (get.sh pins the matching public key). Dormant until the + # BAKERY_MINISIGN_SEC_KEY secret is actually provisioned in this repo's + # Actions settings — until then this step logs a warning and the + # binary ships unsigned, exactly as it does today. + - name: sign release binary + env: + MINISIGN_SEC_KEY_CONTENTS: ${{ secrets.BAKERY_MINISIGN_SEC_KEY }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/bakery/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY_CONTENTS}" ]; then + command -v minisign >/dev/null 2>&1 || { echo "::error::minisign not installed on runner"; exit 1; } + KEY_FILE="$(mktemp)" + trap 'shred -u "${KEY_FILE}" 2>/dev/null || rm -f "${KEY_FILE}"' EXIT + printf '%s' "${MINISIGN_SEC_KEY_CONTENTS}" > "${KEY_FILE}" + minisign -W -S -s "${KEY_FILE}" -m "${PKG_DIR}/bakery-x86_64" \ + -x "${PKG_DIR}/bakery-x86_64.minisig" /dev/null || rm -f "${KEY_FILE}"' EXIT + printf '%s' "${MINISIGN_SEC_KEY_CONTENTS}" > "${KEY_FILE}" + MINISIGN_SEC_KEY="${KEY_FILE}" bash "${GITHUB_WORKSPACE}/scripts/gen-index.sh" + else + bash "${GITHUB_WORKSPACE}/scripts/gen-index.sh" + fi - name: upload to GitHub Release env: @@ -47,7 +80,6 @@ jobs: PKG_DIR="${DL_DIR}/bakery/${VERSION}" gh release create "${GITHUB_REF_NAME}" \ --title "bakery v${VERSION}" --generate-notes 2>/dev/null || true - gh release upload "${GITHUB_REF_NAME}" \ - "${PKG_DIR}/bakery-x86_64" \ - "${PKG_DIR}/bakery-x86_64.sha256" \ - --clobber + ASSETS="${PKG_DIR}/bakery-x86_64 ${PKG_DIR}/bakery-x86_64.sha256" + [ -f "${PKG_DIR}/bakery-x86_64.minisig" ] && ASSETS="${ASSETS} ${PKG_DIR}/bakery-x86_64.minisig" + gh release upload "${GITHUB_REF_NAME}" ${ASSETS} --clobber diff --git a/.gitignore b/.gitignore index b83d222..4c046ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ /target/ + +# minisign secret keys must never be committed — the bakery/index signing +# key lives outside this repo entirely (see scripts/gen-index.sh / +# scripts/get.sh for how it's consumed via MINISIGN_SEC_KEY). +*.minisign-sec +minisign.key diff --git a/BREAD_DESIGN_SYSTEM.md b/BREAD_DESIGN_SYSTEM.md index 942f7a2..ae57901 100644 --- a/BREAD_DESIGN_SYSTEM.md +++ b/BREAD_DESIGN_SYSTEM.md @@ -48,16 +48,27 @@ Establish a visual hierarchy with consistent rounding: ## Color System -All projects use **pywal dynamic theming** with **Catppuccin Mocha** as the fallback palette: +All projects use **pywal dynamic theming** for accents, layered on a **fixed BOS +dark base** — background, surface, overlay, and foreground never come from +pywal, only the accent slots (color1–6) track the current wallpaper: -- **Background**: `#1e1e2e` (Catppuccin) -- **Foreground**: `#cdd6f4` (Catppuccin) -- **Surface**: `#181825` (Catppuccin) -- **Accent**: Dynamic (from pywal) +- **Background**: `#0c0c0c` (fixed) +- **Foreground**: `#e8e8e8` (fixed) +- **Surface**: `#1a1a1a` (fixed, `color0`) +- **Overlay**: `#d8d8d8` (fixed, `color7`) +- **Accent**: Dynamic (from pywal `color4`), with curated bread-toned defaults + before any wallpaper has been set + +Without pinning bg/surface/overlay, a light or muddy-toned wallpaper makes +pywal hand back a light or off-hue background, and every bread GUI's panels +inherit it — see `bread-theme/src/palette.rs` for the implementation. Color palette slots (via wal): -- color0–color7: ANSI colors +- color0–color7: ANSI colors (0 and 7 fixed, 1–6 pywal-derived) - Semantic: red, green, yellow, blue, pink, teal +- Computed ink: `on-bg`, `on-surface`, `on-accent`, `on-red`, `on-overlay` — + black or white text, whichever is legible against that background (see + `bread_theme::ink_on`) ## Component Standards @@ -104,8 +115,9 @@ add only app-specific rules: hardcoded Nord palette; migrated to the shared stylesheet). - **breadcrumbs** — CLI tool; ANSI colours only, no GUI styling. -> Palette note: the fallback is Catppuccin Mocha, but installs (e.g. BOS) drive -> the real palette from pywal — BOS ships a black-base palette. +> Palette note: background/surface/overlay/foreground are a fixed BOS dark +> base, never pywal-derived; only the accent slots (color1–6) track the +> current wallpaper via pywal. ## Future Consistency Checks diff --git a/Cargo.lock b/Cargo.lock index 36707a1..44703a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,7 @@ dependencies = [ "clap", "dirs", "hex", + "minisign-verify", "serde", "serde_json", "sha2", @@ -973,6 +974,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" diff --git a/Cargo.toml b/Cargo.toml index 354c58c..be6b60d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ sha2 = "0.10" hex = "0.4" clap = { version = "4", features = ["derive", "env"] } chrono = "0.4" +minisign-verify = "0.2" [profile.release] lto = "thin" diff --git a/README.md b/README.md index 1ec4cff..c01995a 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ to keys. ## Theming All GUIs share one look via `bread-theme`. The `bread-theme` CLI renders the -component stylesheet from your pywal palette (Catppuccin Mocha fallback) to +component stylesheet from your pywal palette, layered on a fixed BOS dark +base (background/surface/overlay never come from pywal — only the accent +colors do, so a light wallpaper can't wash out the UI), to `$XDG_RUNTIME_DIR/bread/theme.css`; every app loads that file and **live-reloads** it, so changing your wallpaper recolours the whole ecosystem with no rebuilds: @@ -95,8 +97,12 @@ Install all required deps with `sudo pacman -S `. Use `pacman -Q ## Theming All GUI products (breadbar, breadbox, breadpad) read pywal colors from -`~/.cache/wal/colors.json` and fall back to Catppuccin Mocha when that file -is absent. Per-app CSS overrides live at `~/.config//style.css`. +`~/.cache/wal/colors.json` for accents only; background, surface, overlay, +and foreground are always BOS's fixed dark values (see +[`BREAD_DESIGN_SYSTEM.md`](BREAD_DESIGN_SYSTEM.md#color-system)) regardless +of what pywal extracted from the wallpaper. When `colors.json` is absent, +accents fall back to BOS's curated bread-toned defaults. Per-app CSS +overrides live at `~/.config//style.css`. The shared theming logic lives in the `bread-theme` crate in this repo. @@ -107,7 +113,7 @@ This repo is a Cargo workspace: ``` bread-ecosystem/ ├── bakery/ # package manager binary -├── bread-theme/ # shared pywal + Catppuccin theming crate +├── bread-theme/ # shared pywal + fixed-dark-base theming crate ├── registry/ # bread-ecosystem.toml — product registry └── scripts/ ├── get.sh # curl | sh bootstrap diff --git a/bakery/Cargo.toml b/bakery/Cargo.toml index df5b7c1..3664980 100644 --- a/bakery/Cargo.toml +++ b/bakery/Cargo.toml @@ -18,6 +18,7 @@ sha2 = { workspace = true } hex = { workspace = true } clap = { workspace = true } chrono = { workspace = true } +minisign-verify = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/bakery/src/download.rs b/bakery/src/download.rs index c89744c..3362bd0 100644 --- a/bakery/src/download.rs +++ b/bakery/src/download.rs @@ -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()); diff --git a/bakery/src/install.rs b/bakery/src/install.rs index 03fb65f..3b4c925 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -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); } diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 5106646..2a8e7ac 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -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, + /// 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, } #[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 { 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 { + 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 { - let text = fetch_text(PRIMARY_URL)?; +fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result { + 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 { @@ -151,3 +229,51 @@ fn fetch_bytes(url: &str) -> Result> { .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"); + } +} diff --git a/bread-theme/Cargo.toml b/bread-theme/Cargo.toml index 8dc41e7..8547e24 100644 --- a/bread-theme/Cargo.toml +++ b/bread-theme/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true -description = "Shared pywal + Catppuccin theming crate for the bread ecosystem" +description = "Shared pywal-accented, fixed-dark-base theming crate for the bread ecosystem" repository = "https://github.com/Breadway/bread-ecosystem" keywords = ["theming", "pywal", "gtk4", "wayland"] diff --git a/bread-theme/bakery.toml b/bread-theme/bakery.toml index a64c968..548fd3e 100644 --- a/bread-theme/bakery.toml +++ b/bread-theme/bakery.toml @@ -1,5 +1,5 @@ name = "bread-theme" -description = "Shared pywal + Catppuccin theming CLI for the bread ecosystem — generates the shared GTK4 stylesheet every bread app loads" +description = "Shared pywal-accented, fixed-dark-base theming CLI for the bread ecosystem — generates the shared GTK4 stylesheet every bread app loads" binaries = ["bread-theme"] system_deps = [] optional_system_deps = ["python-pywal"] diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index 2ee2307..ab0156b 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -24,31 +24,23 @@ pub mod tokens { pub const RADIUS_PILL: u16 = 999; } -/// Emit the `@define-color` block that all bread apps use. -/// Apps append their own rules below this; user CSS goes on top. +/// Emit the `@define-color` block that all bread apps use, plus the shared +/// font rule. +/// +/// Kept for API compatibility with older callers that only want the color +/// variables (not the full [`stylesheet`] component rules). It used to carry +/// its own hand-written `@define-color` block that predated the `accent` and +/// computed-ink (`on-*`) colors — that duplication is exactly what let it +/// drift out of sync and reintroduce the illegible-text bug (light pywal +/// colors + no computed ink meant white-on-white / black-on-black text +/// wherever a caller's own CSS referenced `@on-surface`, `@on-accent`, etc., +/// since those names simply didn't exist in this block). It now delegates +/// to the same [`define_colors`] the full stylesheet uses, so there is only +/// one color-block implementation and it cannot drift again. pub fn css_vars(p: &Palette) -> String { format!( - "@define-color bg {bg};\n\ - @define-color fg {fg};\n\ - @define-color surface {c0};\n\ - @define-color red {c1};\n\ - @define-color green {c2};\n\ - @define-color yellow {c3};\n\ - @define-color blue {c4};\n\ - @define-color pink {c5};\n\ - @define-color teal {c6};\n\ - @define-color overlay {c7};\n\ - * {{ font-family: '{font}'; font-size: {size}px; }}\n", - bg = p.background, - fg = p.foreground, - c0 = p.color0, - c1 = p.color1, - c2 = p.color2, - c3 = p.color3, - c4 = p.color4, - c5 = p.color5, - c6 = p.color6, - c7 = p.color7, + "{vars}* {{ font-family: '{font}'; font-size: {size}px; }}\n", + vars = define_colors(p), font = tokens::FONT_FAMILY, size = tokens::FONT_SIZE_BASE, ) @@ -241,6 +233,33 @@ mod tests { assert!(css.contains("14px")); } + #[test] + fn css_vars_includes_accent_and_computed_ink_colors() { + // Regression test: css_vars() used to be a second, hand-written + // @define-color block that predated `accent` and the computed `on-*` + // ink colors. Any caller whose own CSS referenced `@on-surface` / + // `@on-accent` etc. against that older block would hit an undefined + // color name — the illegible-text bug. css_vars() must now emit + // exactly the same color set as the full stylesheet. + let css = css_vars(&Palette::default()); + for name in &["accent", "on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] { + assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}"); + } + } + + #[test] + fn css_vars_and_stylesheet_agree_on_color_block() { + // Both must derive their color variables from the same + // `define_colors` implementation, so they can't drift apart again. + let p = Palette::default(); + let vars = css_vars(&p); + let sheet = stylesheet(&p); + for name in &["bg", "fg", "surface", "overlay", "accent", "on-bg", "on-surface", "on-accent"] { + let needle = format!("@define-color {name} "); + assert!(vars.contains(&needle) && sheet.contains(&needle)); + } + } + #[test] fn stylesheet_defines_canonical_colors_and_components() { let css = stylesheet(&Palette::default()); diff --git a/registry/bread-ecosystem.toml b/registry/bread-ecosystem.toml index 06706f0..43abdc5 100644 --- a/registry/bread-ecosystem.toml +++ b/registry/bread-ecosystem.toml @@ -15,7 +15,7 @@ description = "Bread ecosystem package manager" [[products]] name = "bread-theme" repo = "Breadway/bread-ecosystem" -description = "Shared pywal + Catppuccin theming CLI for the bread ecosystem" +description = "Shared pywal-accented, fixed-dark-base theming CLI for the bread ecosystem" [[products]] name = "bread" diff --git a/scripts/gen-index.sh b/scripts/gen-index.sh index 86d3f40..9991adb 100755 --- a/scripts/gen-index.sh +++ b/scripts/gen-index.sh @@ -119,8 +119,12 @@ with open('${bakery_toml}', 'rb') as f: print(json.dumps(d.get('bread_deps', []))) " 2>/dev/null || echo "[]")" - # [[service]] entries → [{unit, enable}] - services="$(python3 -c " + # [[service]] entries → [{unit, enable, sha256}]. sha256 comes from the + # actual unit file shipped in this version dir — the same + # artifact-integrity guarantee binaries already get. A missing unit file + # gets an empty sha256; install.rs refuses to install an unverified + # download rather than silently skipping the check. + service_units="$(python3 -c " import tomllib, json with open('${bakery_toml}', 'rb') as f: d = tomllib.load(f) @@ -128,7 +132,24 @@ svcs = d.get('service', []) print(json.dumps([{'unit': s['unit'], 'enable': s.get('enable', False)} for s in svcs])) " 2>/dev/null || echo "[]")" - # [config] → {dir, example?} or null + services="[]" + while IFS= read -r svc_entry; do + [[ -z "${svc_entry}" ]] && continue + unit_name="$(echo "${svc_entry}" | jq -r '.unit')" + enable="$(echo "${svc_entry}" | jq -r '.enable')" + unit_path="${version_dir}/${unit_name}" + unit_sha256="" + if [[ -f "${unit_path}" ]]; then + unit_sha256="$(sha256sum "${unit_path}" | awk '{print $1}')" + else + echo " warning: service unit '${unit_name}' not found at ${unit_path}" >&2 + fi + svc_json="$(jq -n --arg unit "${unit_name}" --argjson enable "${enable}" --arg sha256 "${unit_sha256}" \ + '{unit: $unit, enable: $enable, sha256: $sha256}')" + services="$(jq -n --argjson arr "${services}" --argjson e "${svc_json}" '$arr + [$e]')" + done < <(echo "${service_units}" | jq -c '.[]') + + # [config] → {dir, example?, example_sha256?} or null config="$(python3 -c " import tomllib, json with open('${bakery_toml}', 'rb') as f: @@ -142,6 +163,19 @@ if cfg: else: print('null') " 2>/dev/null || echo "null")" + if [[ "${config}" != "null" ]]; then + example_name="$(echo "${config}" | jq -r '.example // empty')" + if [[ -n "${example_name}" ]]; then + example_path="${version_dir}/${example_name}" + example_sha256="" + if [[ -f "${example_path}" ]]; then + example_sha256="$(sha256sum "${example_path}" | awk '{print $1}')" + else + echo " warning: config.example '${example_name}' not found at ${example_path}" >&2 + fi + config="$(echo "${config}" | jq -c --arg sha "${example_sha256}" '. + {example_sha256: $sha}')" + fi + fi post_install="$(python3 -c " import tomllib, json @@ -194,3 +228,42 @@ jq -n \ > "${OUT}" echo "wrote ${OUT}" + +# Sign the index so `bakery` can verify it before trusting a single byte. +# Every artifact sha256 and post_install hook string lives inside index.json, +# so a valid signature over these raw bytes transitively covers all of it — +# no separate per-artifact signing is needed. +# +# MINISIGN_SEC_KEY must point at the *secret* key file generated with +# `minisign -G`. It is intentionally never read from inside either git repo; +# point it at wherever the key actually lives on the machine that runs this +# script (e.g. a root-only path on hestia), and set MINISIGN_SEC_KEY_PASSWORD +# too if the key was generated with a password. +# +# This step is a no-op (with a loud warning) if the key isn't configured, so +# existing unsigned publishing flows don't break until the key is actually +# wired up — see the handoff note in the fix commit for this repo. +if [[ -n "${MINISIGN_SEC_KEY:-}" ]]; then + if [[ ! -f "${MINISIGN_SEC_KEY}" ]]; then + echo "ERROR: MINISIGN_SEC_KEY=${MINISIGN_SEC_KEY} does not exist" >&2 + exit 1 + fi + if ! command -v minisign >/dev/null 2>&1; then + echo "ERROR: MINISIGN_SEC_KEY is set but the 'minisign' binary is not installed" >&2 + exit 1 + fi + sign_args=(-S -s "${MINISIGN_SEC_KEY}" -m "${OUT}" -x "${OUT}.minisig") + if [[ -n "${MINISIGN_SEC_KEY_PASSWORD:-}" ]]; then + MINISIGN_PASSWORD="${MINISIGN_SEC_KEY_PASSWORD}" minisign "${sign_args[@]}" ${OUT}.minisig" +else + echo "WARNING: MINISIGN_SEC_KEY not set — index.json was NOT signed." >&2 + echo " bakery clients built with signature verification will reject" >&2 + echo " this index. Set MINISIGN_SEC_KEY before running this in" >&2 + echo " production once the signing key has been provisioned." >&2 +fi diff --git a/scripts/get.sh b/scripts/get.sh index cc343f0..a2df2eb 100755 --- a/scripts/get.sh +++ b/scripts/get.sh @@ -4,6 +4,13 @@ # Or: curl -sSfL https://breadway.dev/get | sh set -eu +# Pinned minisign public key for the bakery release binary. Matches the +# PUBKEY constant in bakery/src/manifest.rs (same keypair signs both +# index.json and the bakery binary itself). Do not source this from the +# network — it must be baked into this script so a compromised dl server +# can't swap it out along with a malicious binary. +BAKERY_MINISIGN_PUBKEY="RWRh2Zr5SUinvVFCtD7S7HwGjfrye6j31Xq2mYXRdkGFDWe3yHF7W11K" + BAKERY_VERSION="${BAKERY_VERSION:-latest}" BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}" @@ -19,12 +26,16 @@ if [ "${BAKERY_VERSION}" = "latest" ]; then DL_PRIMARY="https://dl.breadway.dev/bakery/latest/bakery-x86_64" DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/latest/download/bakery-x86_64" SHA256_URL="https://dl.breadway.dev/bakery/latest/bakery-x86_64.sha256" + SIG_URL="https://dl.breadway.dev/bakery/latest/bakery-x86_64.minisig" + SIG_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/latest/download/bakery-x86_64.minisig" else # Strip a leading 'v' if the caller included it, then add it back consistently. ver="${BAKERY_VERSION#v}" DL_PRIMARY="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64" DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/v${ver}/bakery-x86_64" SHA256_URL="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64.sha256" + SIG_URL="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64.minisig" + SIG_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/v${ver}/bakery-x86_64.minisig" fi # Pick a download tool. @@ -38,30 +49,63 @@ fi mkdir -p "${BIN_DIR}" TMP="$(mktemp)" -trap 'rm -f "${TMP}" "${TMP}.sha256"' EXIT +trap 'rm -f "${TMP}" "${TMP}.sha256" "${TMP}.minisig"' EXIT echo "downloading bakery…" if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then echo " from dl.breadway.dev" - # Verify checksum when available from primary. - if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then - expected="$(awk '{print $1}' "${TMP}.sha256")" - actual="$(sha256sum "${TMP}" | awk '{print $1}')" - if [ "${expected}" != "${actual}" ]; then - die "SHA-256 checksum mismatch (expected ${expected}, got ${actual})" - fi - echo " checksum verified" - else - echo " warning: could not fetch checksum — skipping verification" - fi + sig_url="${SIG_URL}" + checksum_only_fallback_note=" warning: could not fetch checksum — skipping verification" elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then echo " from GitHub (fallback)" - # No .sha256 on the GitHub fallback path; proceed without verification. - echo " warning: checksum not verified for GitHub fallback download" + sig_url="${SIG_FALLBACK}" + checksum_only_fallback_note=" warning: no checksum available for GitHub fallback download" else die "failed to download bakery from both primary and fallback URLs" fi +# Signature verification is the authoritative check: it proves the binary +# was produced by whoever holds the bakery signing key, not just that bytes +# match whatever the same (possibly compromised) server also reports as the +# checksum. Prefer it whenever both a .minisig is published and a minisign +# verifier is available on this machine. +sig_verified=0 +if fetch "${sig_url}" "${TMP}.minisig" 2>/dev/null; then + if command -v minisign >/dev/null 2>&1; then + if minisign -V -q -m "${TMP}" -x "${TMP}.minisig" -P "${BAKERY_MINISIGN_PUBKEY}"; then + echo " signature verified (minisign)" + sig_verified=1 + else + die "minisign signature verification FAILED — refusing to install a binary that doesn't match the pinned bakery key" + fi + else + echo " warning: 'minisign' is not installed — cannot verify the binary's" >&2 + echo " warning: signature, only its checksum. Install minisign for the" >&2 + echo " warning: strongest guarantee: pacman -S minisign / apt install minisign" >&2 + fi +else + echo " warning: no .minisig published for this release yet — signature not verified" >&2 +fi + +# Checksum is a secondary, best-effort check (kept for defense in depth and +# for the case where minisign isn't installed). It is not a substitute for +# signature verification: both the binary and its checksum typically come +# from the same server, so a compromised server can serve a matching pair. +if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then + expected="$(awk '{print $1}' "${TMP}.sha256")" + actual="$(sha256sum "${TMP}" | awk '{print $1}')" + if [ "${expected}" != "${actual}" ]; then + die "SHA-256 checksum mismatch (expected ${expected}, got ${actual})" + fi + echo " checksum verified" +else + echo "${checksum_only_fallback_note}" +fi + +if [ "${sig_verified}" -ne 1 ]; then + echo " warning: proceeding WITHOUT a verified signature on the bakery binary" >&2 +fi + chmod +x "${TMP}" cp "${TMP}" "${BIN_DIR}/bakery" echo "installed bakery to ${BIN_DIR}/bakery" diff --git a/scripts/test-gen-index.sh b/scripts/test-gen-index.sh index 5a2733a..a496ca0 100755 --- a/scripts/test-gen-index.sh +++ b/scripts/test-gen-index.sh @@ -110,4 +110,12 @@ check "post_install[0]" \ "echo installed" \ "$(jq -r '.packages.fakepkg.post_install[0]' "${OUT}")" +check "services[0].sha256" \ + "$(sha256sum "${PKG_VER_DIR}/fakepkg.service" | awk '{print $1}')" \ + "$(jq -r '.packages.fakepkg.services[0].sha256' "${OUT}")" + +check "config.example_sha256" \ + "$(sha256sum "${PKG_VER_DIR}/fakepkg.example.toml" | awk '{print $1}')" \ + "$(jq -r '.packages.fakepkg.config.example_sha256' "${OUT}")" + echo "OK: all gen-index assertions passed"