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

@ -31,7 +31,31 @@ jobs:
cp src/bread-theme/bakery.toml "${PKG_DIR}/bakery.toml" cp src/bread-theme/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/bread-theme/latest" 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
echo "signed bread-theme-x86_64"
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bread-theme-x86_64 UNSIGNED"
fi
- name: regenerate index.json - name: regenerate index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: cd src && bash scripts/gen-index.sh run: cd src && bash scripts/gen-index.sh
- name: upload to GitHub Release - name: upload to GitHub Release
@ -43,7 +67,6 @@ jobs:
PKG_DIR="/srv/breadway-dl/bread-theme/${VERSION}" PKG_DIR="/srv/breadway-dl/bread-theme/${VERSION}"
gh release create "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem \ gh release create "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem \
--title "bread-ecosystem ${GITHUB_REF_NAME}" --generate-notes 2>/dev/null || true --title "bread-ecosystem ${GITHUB_REF_NAME}" --generate-notes 2>/dev/null || true
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem \ ASSETS="${PKG_DIR}/bread-theme-x86_64 ${PKG_DIR}/bread-theme-x86_64.sha256"
"${PKG_DIR}/bread-theme-x86_64" \ [ -f "${PKG_DIR}/bread-theme-x86_64.minisig" ] && ASSETS="${ASSETS} ${PKG_DIR}/bread-theme-x86_64.minisig"
"${PKG_DIR}/bread-theme-x86_64.sha256" \ gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem ${ASSETS} --clobber
--clobber

View file

@ -36,8 +36,41 @@ jobs:
cp bakery.toml "${PKG_DIR}/bakery.toml" cp bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "${DL_DIR}/bakery/latest" 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
echo "signed bakery-x86_64"
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY secret not set — shipping bakery-x86_64 UNSIGNED"
fi
- name: regenerate index.json - name: regenerate index.json
run: bash "${GITHUB_WORKSPACE}/scripts/gen-index.sh" env:
MINISIGN_SEC_KEY_CONTENTS: ${{ secrets.BAKERY_MINISIGN_SEC_KEY }}
run: |
if [ -n "${MINISIGN_SEC_KEY_CONTENTS}" ]; then
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_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 - name: upload to GitHub Release
env: env:
@ -47,7 +80,6 @@ jobs:
PKG_DIR="${DL_DIR}/bakery/${VERSION}" PKG_DIR="${DL_DIR}/bakery/${VERSION}"
gh release create "${GITHUB_REF_NAME}" \ gh release create "${GITHUB_REF_NAME}" \
--title "bakery v${VERSION}" --generate-notes 2>/dev/null || true --title "bakery v${VERSION}" --generate-notes 2>/dev/null || true
gh release upload "${GITHUB_REF_NAME}" \ ASSETS="${PKG_DIR}/bakery-x86_64 ${PKG_DIR}/bakery-x86_64.sha256"
"${PKG_DIR}/bakery-x86_64" \ [ -f "${PKG_DIR}/bakery-x86_64.minisig" ] && ASSETS="${ASSETS} ${PKG_DIR}/bakery-x86_64.minisig"
"${PKG_DIR}/bakery-x86_64.sha256" \ gh release upload "${GITHUB_REF_NAME}" ${ASSETS} --clobber
--clobber

6
.gitignore vendored
View file

@ -1 +1,7 @@
/target/ /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

View file

@ -48,16 +48,27 @@ Establish a visual hierarchy with consistent rounding:
## Color System ## 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 (color16) track the current wallpaper:
- **Background**: `#1e1e2e` (Catppuccin) - **Background**: `#0c0c0c` (fixed)
- **Foreground**: `#cdd6f4` (Catppuccin) - **Foreground**: `#e8e8e8` (fixed)
- **Surface**: `#181825` (Catppuccin) - **Surface**: `#1a1a1a` (fixed, `color0`)
- **Accent**: Dynamic (from pywal) - **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): Color palette slots (via wal):
- color0color7: ANSI colors - color0color7: ANSI colors (0 and 7 fixed, 16 pywal-derived)
- Semantic: red, green, yellow, blue, pink, teal - 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 ## Component Standards
@ -104,8 +115,9 @@ add only app-specific rules:
hardcoded Nord palette; migrated to the shared stylesheet). hardcoded Nord palette; migrated to the shared stylesheet).
- **breadcrumbs** — CLI tool; ANSI colours only, no GUI styling. - **breadcrumbs** — CLI tool; ANSI colours only, no GUI styling.
> Palette note: the fallback is Catppuccin Mocha, but installs (e.g. BOS) drive > Palette note: background/surface/overlay/foreground are a fixed BOS dark
> the real palette from pywal — BOS ships a black-base palette. > base, never pywal-derived; only the accent slots (color16) track the
> current wallpaper via pywal.
## Future Consistency Checks ## Future Consistency Checks

7
Cargo.lock generated
View file

@ -88,6 +88,7 @@ dependencies = [
"clap", "clap",
"dirs", "dirs",
"hex", "hex",
"minisign-verify",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@ -973,6 +974,12 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"

View file

@ -19,6 +19,7 @@ sha2 = "0.10"
hex = "0.4" hex = "0.4"
clap = { version = "4", features = ["derive", "env"] } clap = { version = "4", features = ["derive", "env"] }
chrono = "0.4" chrono = "0.4"
minisign-verify = "0.2"
[profile.release] [profile.release]
lto = "thin" lto = "thin"

View file

@ -36,7 +36,9 @@ to keys.
## Theming ## Theming
All GUIs share one look via `bread-theme`. The `bread-theme` CLI renders the 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** `$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: it, so changing your wallpaper recolours the whole ecosystem with no rebuilds:
@ -95,8 +97,12 @@ Install all required deps with `sudo pacman -S <packages>`. Use `pacman -Q <pkg>
## Theming ## Theming
All GUI products (breadbar, breadbox, breadpad) read pywal colors from All GUI products (breadbar, breadbox, breadpad) read pywal colors from
`~/.cache/wal/colors.json` and fall back to Catppuccin Mocha when that file `~/.cache/wal/colors.json` for accents only; background, surface, overlay,
is absent. Per-app CSS overrides live at `~/.config/<app>/style.css`. 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/<app>/style.css`.
The shared theming logic lives in the `bread-theme` crate in this repo. 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/ bread-ecosystem/
├── bakery/ # package manager binary ├── 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 ├── registry/ # bread-ecosystem.toml — product registry
└── scripts/ └── scripts/
├── get.sh # curl | sh bootstrap ├── get.sh # curl | sh bootstrap

View file

@ -18,6 +18,7 @@ sha2 = { workspace = true }
hex = { workspace = true } hex = { workspace = true }
clap = { workspace = true } clap = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
minisign-verify = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View file

@ -32,7 +32,12 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> {
Ok(()) 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(); let mut hasher = Sha256::new();
hasher.update(bytes); hasher.update(bytes);
let actual = hex::encode(hasher.finalize()); let actual = hex::encode(hasher.finalize());

View file

@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; 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::manifest::{fetch_binary, Package, Service};
use crate::state::{InstalledPackage, State}; use crate::state::{InstalledPackage, State};
@ -119,11 +119,28 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu
if !dest.exists() { if !dest.exists() {
if let Some((primary, fallback)) = pkg.artifact_urls(example) { if let Some((primary, fallback)) = pkg.artifact_urls(example) {
match fetch_binary(&primary, &fallback) { match fetch_binary(&primary, &fallback) {
Ok(bytes) => { Ok(bytes) => match &cfg.example_sha256 {
std::fs::write(&dest, &bytes) Some(expected) => match verify_sha256(&bytes, expected) {
.with_context(|| format!("writing {}", dest.display()))?; Ok(()) => {
println!(" installed example config at {}", dest.display()); 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) => { Err(e) => {
eprintln!(" warning: could not download example config {example}: {e}"); eprintln!(" warning: could not download example config {example}: {e}");
println!(" config dir created at {}", dir.display()); 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 !unit_path.exists() {
if let Some((primary, fallback)) = pkg.artifact_urls(&svc.unit) { if let Some((primary, fallback)) = pkg.artifact_urls(&svc.unit) {
match fetch_binary(&primary, &fallback) { match fetch_binary(&primary, &fallback) {
Ok(bytes) => { Ok(bytes) => match verify_sha256(&bytes, &svc.sha256) {
std::fs::write(&unit_path, &bytes) Ok(()) => {
.with_context(|| format!("writing {}", unit_path.display()))?; std::fs::write(&unit_path, &bytes)
println!(" downloaded unit {}", unit_path.display()); .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) => { Err(e) => {
eprintln!(" warning: could not download {}: {e}", svc.unit); eprintln!(" warning: could not download {}: {e}", svc.unit);
} }

View file

@ -1,11 +1,45 @@
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use minisign_verify::{PublicKey, Signature};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
const PRIMARY_URL: &str = "https://dl.breadway.dev/index.json"; 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); 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)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Binary { pub struct Binary {
pub name: String, pub name: String,
@ -18,6 +52,10 @@ pub struct Binary {
pub struct Service { pub struct Service {
pub unit: String, pub unit: String,
pub enable: bool, 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)] #[derive(Debug, Clone, Deserialize, Serialize)]
@ -25,6 +63,10 @@ pub struct ConfigScaffold {
pub dir: String, pub dir: String,
/// Example config filename, relative to the release artifact directory. /// Example config filename, relative to the release artifact directory.
pub example: Option<String>, 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)] #[derive(Debug, Clone, Deserialize, Serialize)]
@ -80,15 +122,38 @@ impl Index {
/// Load the manifest, using the on-disk cache when it is fresh enough. /// Load the manifest, using the on-disk cache when it is fresh enough.
/// Always fetches if `force_refresh` is true. /// 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> { pub fn load(force_refresh: bool) -> Result<Index> {
let cache_path = cache_path(); let cache_path = cache_path();
let sig_cache_path = sig_cache_path(&cache_path);
if !force_refresh && cache_is_fresh(&cache_path) { if !force_refresh && cache_is_fresh(&cache_path) {
let text = std::fs::read_to_string(&cache_path).context("reading cached index")?; match read_and_verify_cache(&cache_path, &sig_cache_path) {
return serde_json::from_str(&text).context("parsing cached index"); 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 { fn cache_is_fresh(path: &PathBuf) -> bool {
@ -98,13 +163,26 @@ fn cache_is_fresh(path: &PathBuf) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
fn fetch_and_cache(cache_path: &PathBuf) -> Result<Index> { fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result<Index> {
let text = fetch_text(PRIMARY_URL)?; 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() { if let Some(dir) = cache_path.parent() {
std::fs::create_dir_all(dir)?; std::fs::create_dir_all(dir)?;
} }
std::fs::write(cache_path, &text)?; std::fs::write(cache_path, &bytes)?;
serde_json::from_str(&text).context("parsing index.json") 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> { fn fetch_text(url: &str) -> Result<String> {
@ -151,3 +229,51 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
.context("reading response")?; .context("reading response")?;
Ok(buf) 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");
}
}

View file

@ -4,7 +4,7 @@ version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
authors.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" repository = "https://github.com/Breadway/bread-ecosystem"
keywords = ["theming", "pywal", "gtk4", "wayland"] keywords = ["theming", "pywal", "gtk4", "wayland"]

View file

@ -1,5 +1,5 @@
name = "bread-theme" 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"] binaries = ["bread-theme"]
system_deps = [] system_deps = []
optional_system_deps = ["python-pywal"] optional_system_deps = ["python-pywal"]

View file

@ -24,31 +24,23 @@ pub mod tokens {
pub const RADIUS_PILL: u16 = 999; pub const RADIUS_PILL: u16 = 999;
} }
/// Emit the `@define-color` block that all bread apps use. /// Emit the `@define-color` block that all bread apps use, plus the shared
/// Apps append their own rules below this; user CSS goes on top. /// 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 { pub fn css_vars(p: &Palette) -> String {
format!( format!(
"@define-color bg {bg};\n\ "{vars}* {{ font-family: '{font}'; font-size: {size}px; }}\n",
@define-color fg {fg};\n\ vars = define_colors(p),
@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,
font = tokens::FONT_FAMILY, font = tokens::FONT_FAMILY,
size = tokens::FONT_SIZE_BASE, size = tokens::FONT_SIZE_BASE,
) )
@ -241,6 +233,33 @@ mod tests {
assert!(css.contains("14px")); 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] #[test]
fn stylesheet_defines_canonical_colors_and_components() { fn stylesheet_defines_canonical_colors_and_components() {
let css = stylesheet(&Palette::default()); let css = stylesheet(&Palette::default());

View file

@ -15,7 +15,7 @@ description = "Bread ecosystem package manager"
[[products]] [[products]]
name = "bread-theme" name = "bread-theme"
repo = "Breadway/bread-ecosystem" 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]] [[products]]
name = "bread" name = "bread"

View file

@ -119,8 +119,12 @@ with open('${bakery_toml}', 'rb') as f:
print(json.dumps(d.get('bread_deps', []))) print(json.dumps(d.get('bread_deps', [])))
" 2>/dev/null || echo "[]")" " 2>/dev/null || echo "[]")"
# [[service]] entries → [{unit, enable}] # [[service]] entries → [{unit, enable, sha256}]. sha256 comes from the
services="$(python3 -c " # 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 import tomllib, json
with open('${bakery_toml}', 'rb') as f: with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(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])) print(json.dumps([{'unit': s['unit'], 'enable': s.get('enable', False)} for s in svcs]))
" 2>/dev/null || echo "[]")" " 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 " config="$(python3 -c "
import tomllib, json import tomllib, json
with open('${bakery_toml}', 'rb') as f: with open('${bakery_toml}', 'rb') as f:
@ -142,6 +163,19 @@ if cfg:
else: else:
print('null') print('null')
" 2>/dev/null || echo "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 " post_install="$(python3 -c "
import tomllib, json import tomllib, json
@ -194,3 +228,42 @@ jq -n \
> "${OUT}" > "${OUT}"
echo "wrote ${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[@]}" </dev/null
else
# -W: the key has no password (matches how CI-facing signing keys are
# normally generated, since there's no human to type a passphrase).
minisign -W "${sign_args[@]}" </dev/null
fi
echo "signed ${OUT} -> ${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

View file

@ -4,6 +4,13 @@
# Or: curl -sSfL https://breadway.dev/get | sh # Or: curl -sSfL https://breadway.dev/get | sh
set -eu 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}" BAKERY_VERSION="${BAKERY_VERSION:-latest}"
BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}" 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_PRIMARY="https://dl.breadway.dev/bakery/latest/bakery-x86_64"
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/latest/download/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" 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 else
# Strip a leading 'v' if the caller included it, then add it back consistently. # Strip a leading 'v' if the caller included it, then add it back consistently.
ver="${BAKERY_VERSION#v}" ver="${BAKERY_VERSION#v}"
DL_PRIMARY="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64" 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" 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" 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 fi
# Pick a download tool. # Pick a download tool.
@ -38,30 +49,63 @@ fi
mkdir -p "${BIN_DIR}" mkdir -p "${BIN_DIR}"
TMP="$(mktemp)" TMP="$(mktemp)"
trap 'rm -f "${TMP}" "${TMP}.sha256"' EXIT trap 'rm -f "${TMP}" "${TMP}.sha256" "${TMP}.minisig"' EXIT
echo "downloading bakery…" echo "downloading bakery…"
if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then
echo " from dl.breadway.dev" echo " from dl.breadway.dev"
# Verify checksum when available from primary. sig_url="${SIG_URL}"
if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then checksum_only_fallback_note=" warning: could not fetch checksum — skipping verification"
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
elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then
echo " from GitHub (fallback)" echo " from GitHub (fallback)"
# No .sha256 on the GitHub fallback path; proceed without verification. sig_url="${SIG_FALLBACK}"
echo " warning: checksum not verified for GitHub fallback download" checksum_only_fallback_note=" warning: no checksum available for GitHub fallback download"
else else
die "failed to download bakery from both primary and fallback URLs" die "failed to download bakery from both primary and fallback URLs"
fi 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}" chmod +x "${TMP}"
cp "${TMP}" "${BIN_DIR}/bakery" cp "${TMP}" "${BIN_DIR}/bakery"
echo "installed bakery to ${BIN_DIR}/bakery" echo "installed bakery to ${BIN_DIR}/bakery"

View file

@ -110,4 +110,12 @@ check "post_install[0]" \
"echo installed" \ "echo installed" \
"$(jq -r '.packages.fakepkg.post_install[0]' "${OUT}")" "$(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" echo "OK: all gen-index assertions passed"