diff --git a/Cargo.lock b/Cargo.lock index 74b4c8d3..beaa908a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2880,6 +2880,7 @@ dependencies = [ "mdns-sd", "opus", "pf-ffvk", + "pf-update-check", "pipewire", "punktfunk-core", "pyrowave-sys", @@ -3055,6 +3056,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "pf-update-check" +version = "0.22.3" +dependencies = [ + "anyhow", + "base64", + "ring", + "serde", + "serde_json", + "ureq", +] + [[package]] name = "pf-vdisplay" version = "0.22.3" @@ -3457,6 +3470,7 @@ dependencies = [ "pf-host-config", "pf-inject", "pf-paths", + "pf-update-check", "pf-vdisplay", "pf-win-display", "pf-zerocopy", diff --git a/Cargo.toml b/Cargo.toml index a86d73e4..12bcfbdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/pf-driver-proto", "crates/pf-paths", "crates/pf-update", + "crates/pf-update-check", "crates/pf-host-config", "crates/pf-gpu", "crates/pf-zerocopy", diff --git a/crates/pf-update-check/Cargo.toml b/crates/pf-update-check/Cargo.toml new file mode 100644 index 00000000..f011787b --- /dev/null +++ b/crates/pf-update-check/Cargo.toml @@ -0,0 +1,34 @@ +# The update-CHECK core, shared by the host and the Linux client (planning: +# host-update-from-web-console.md §3). It exists because both products answer the same +# question — "does a newer build exist for this box's channel?" — from the same signed +# manifest, and a trust rule that lives in two places is a trust rule that will diverge. +# +# Deliberately check-only: no apply legs, no package managers, no privileged anything. The +# host keeps its Windows/Linux apply code; the client keeps its own. What is shared here is +# exactly the part where being wrong is a security bug: signature verification, manifest +# validation, and the version comparison that decides whether to tell a user to update. +[package] +name = "pf-update-check" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Signed update-manifest fetch + verification, install-kind detection and version comparison, shared by the punktfunk host and client." +publish = false + +[dependencies] +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Ed25519 over the exact manifest bytes. The workspace is ring-only (no aws-lc-sys — it fails +# on the Windows CI runner), and this is the same primitive the plugin-store index uses. +ring = "0.17" +base64 = "0.22" +# Small, sync, bundles webpki roots — no system cert store dependency, which matters on the +# Deck (Decky's embedded Python has no usable roots either; see clients/decky/main.py). +ureq = "2" + +[lints] +workspace = true diff --git a/crates/pf-update-check/src/detect.rs b/crates/pf-update-check/src/detect.rs new file mode 100644 index 00000000..cab02330 --- /dev/null +++ b/crates/pf-update-check/src/detect.rs @@ -0,0 +1,391 @@ +//! **How was this installed, and on which channel?** (design §4.1) +//! +//! The apply strategy — and, where no apply leg exists, the command hint a UI shows — hangs +//! off the install kind. Detection is a ladder over facts nothing request-side can influence: +//! packaging writes a root-owned marker, a sysext self-identifies via its merged +//! extension-release, a flatpak by its sandbox, Nix by store path, and so on. +//! +//! The ladder is shared between the host and the client because the delivery channels are the +//! same ones; what differs is spelled out in [`Product`] — which marker file to read, whether +//! a flatpak rung exists at all, and what a user-owned binary means. The ladder itself is a +//! pure function over a [`Probe`], so every rung is unit-testable without a box. + +use crate::version::{conf_channel, windows_channel_of, Channel}; +use std::path::{Path, PathBuf}; + +/// Which punktfunk program is asking. The rungs differ (see the module docs), and mixing them +/// up would misreport a client-only box as an un-updatable host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Product { + Host, + Client, +} + +impl Product { + /// Where this product's packaging stamps how it was installed. First word = kind + /// (`apt`|`dnf`|`pacman`), optional second word = channel (`stable`|`canary`). + /// + /// The two products use SEPARATE files on purpose: a box can carry both packages, and + /// every packaging format we ship treats two packages owning one path as a hard conflict. + /// + /// The client's lives in its own DIRECTORY, not just under its own name, because the host + /// RPM claims `%{_datadir}/punktfunk/*` with a glob — a sibling file in there would be + /// owned by both subpackages and `dnf install punktfunk punktfunk-client` would refuse. + pub fn marker_path(self) -> &'static str { + match self { + Product::Host => "/usr/share/punktfunk/install-kind", + Product::Client => "/usr/share/punktfunk-client/install-kind", + } + } + + /// The merged sysext names itself here (written by the sysext build scripts); its presence + /// means the running `/usr` overlay came from that image, regardless of any leftover marker. + pub fn sysext_marker(self) -> &'static str { + match self { + Product::Host => "/usr/lib/extension-release.d/extension-release.punktfunk", + Product::Client => "/usr/lib/extension-release.d/extension-release.punktfunk-client", + } + } + + /// The binary name, for the command hints. + pub fn binary(self) -> &'static str { + match self { + Product::Host => "punktfunk-host", + Product::Client => "punktfunk-client", + } + } +} + +/// The sysext updater's own config (`CHANNEL=stable|canary`). +const SYSEXT_CONF: &str = "/etc/punktfunk-sysext.conf"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallKind { + WindowsInstaller, + /// Client only — the sandboxed GTK app (`io.unom.Punktfunk`). + Flatpak, + Sysext, + RpmOstree, + Apt, + Dnf, + Pacman, + SteamosSource, + Nix, + Source, +} + +impl InstallKind { + pub fn as_str(self) -> &'static str { + match self { + InstallKind::WindowsInstaller => "windows-installer", + InstallKind::Flatpak => "flatpak", + InstallKind::Sysext => "sysext", + InstallKind::RpmOstree => "rpm-ostree", + InstallKind::Apt => "apt", + InstallKind::Dnf => "dnf", + InstallKind::Pacman => "pacman", + InstallKind::SteamosSource => "steamos-source", + InstallKind::Nix => "nix", + InstallKind::Source => "source", + } + } +} + +/// The facts the ladder reads, gathered once by [`gather`] (tests build these directly). +#[derive(Debug, Default)] +pub struct Probe { + /// Running on Windows (cfg, not a file). + pub windows: bool, + /// The running exe's path. + pub exe: PathBuf, + /// `$HOME`, if any. + pub home: Option, + /// This process is inside a flatpak sandbox. + pub flatpak: bool, + /// Contents of [`Product::marker_path`], if present. + pub marker: Option, + /// [`Product::sysext_marker`] exists (merged sysext overlay). + pub sysext: bool, + /// Contents of [`SYSEXT_CONF`], if present. + pub sysext_conf: Option, + /// `/run/ostree-booted` exists (rpm-ostree / bootc family). + pub ostree_booted: bool, + /// The asking binary's own version string, for the Windows channel heuristic. + pub version: String, +} + +/// Gather the real probe for `product`. Cheap (a few `stat`s and two small reads); consumers +/// cache the classification, not this. +pub fn gather(product: Product, version: &str) -> Probe { + Probe { + windows: cfg!(target_os = "windows"), + exe: std::env::current_exe().unwrap_or_default(), + home: std::env::var_os("HOME").map(PathBuf::from), + // Both are set by flatpak inside the sandbox; the exe path is the belt to that + // env-var braces, since a portal-spawned process can inherit a stripped environment. + flatpak: product == Product::Client + && (std::env::var_os("FLATPAK_ID").is_some() || Path::new("/.flatpak-info").exists()), + marker: std::fs::read_to_string(product.marker_path()).ok(), + sysext: Path::new(product.sysext_marker()).exists(), + sysext_conf: std::fs::read_to_string(SYSEXT_CONF).ok(), + ostree_booted: Path::new("/run/ostree-booted").exists(), + version: version.to_string(), + } +} + +/// The ladder (design §4.1). Order matters and each rung is a fact the caller can't forge: +/// flatpak sandbox > sysext overlay > Nix store path > dev/source tree > user-owned Deck +/// build > package marker (flipped to rpm-ostree when the box is ostree-booted) > `source`. +pub fn classify(p: &Probe, product: Product) -> (InstallKind, Channel) { + if p.windows { + // The installer is the only supported Windows delivery; a loose cargo build shows + // itself by not living under Program Files. Channel: canary installers carry the CI + // run as the third component (`M.m.`), see `windows_channel_of`. + let installed = p + .exe + .to_string_lossy() + .to_ascii_lowercase() + .contains("\\program files\\punktfunk"); + return if installed { + ( + InstallKind::WindowsInstaller, + windows_channel_of(&p.version), + ) + } else { + (InstallKind::Source, Channel::Stable) + }; + } + + // Inside the sandbox `/usr` is the runtime's, so every file rung below would read the + // WRONG box's facts. This must stay first. + if p.flatpak { + return (InstallKind::Flatpak, Channel::Stable); + } + + if p.sysext { + let channel = p + .sysext_conf + .as_deref() + .and_then(conf_channel) + .unwrap_or(Channel::Stable); + return (InstallKind::Sysext, channel); + } + + if p.exe.starts_with("/nix/store") { + return (InstallKind::Nix, Channel::Stable); + } + + // A cargo tree anywhere (CI, dev box, the Deck checkout mid-build) is `source`; the + // Deck's install script runs the binary out of `~/punktfunk/target-steamos/`, which is + // user-owned but NOT a plain `target/` dir — that distinction is the marker here. + let exe_str = p.exe.to_string_lossy().to_string(); + if exe_str.contains("/target/") { + return (InstallKind::Source, Channel::Stable); + } + if let Some(home) = &p.home { + if p.exe.starts_with(home) { + // Only the HOST has an on-device Deck build (scripts/steamdeck/update.sh builds + // the host). A client binary under $HOME is someone's own build or a copy into + // ~/.local/bin — nothing knows how to update it, so say `source` and mean it. + return match product { + Product::Host => (InstallKind::SteamosSource, Channel::Canary), + Product::Client => (InstallKind::Source, Channel::Stable), + }; + } + } + + if let Some(marker) = &p.marker { + let mut words = marker.split_whitespace(); + let kind = words.next().unwrap_or(""); + let channel = match words.next() { + Some("canary") => Channel::Canary, + _ => Channel::Stable, + }; + let kind = match kind { + "apt" => Some(InstallKind::Apt), + // An ostree-booted box consumed the RPM by layering (or an image build); either + // way `dnf upgrade` is not how it updates. + "dnf" if p.ostree_booted => Some(InstallKind::RpmOstree), + "dnf" => Some(InstallKind::Dnf), + "pacman" => Some(InstallKind::Pacman), + _ => None, + }; + if let Some(kind) = kind { + return (kind, channel); + } + } + + (InstallKind::Source, Channel::Stable) +} + +/// The per-kind "how to update" command a UI shows while (or instead of) an apply path +/// existing (design §5). One line, copy-pastable, no placeholders. +pub fn update_command(kind: InstallKind, product: Product) -> String { + let bin = product.binary(); + match (kind, product) { + (InstallKind::WindowsInstaller, _) => { + "winget upgrade unom.PunktfunkHost (or re-run the newer installer)".into() + } + (InstallKind::Flatpak, _) => "flatpak update --user io.unom.Punktfunk".into(), + // The signed sysext feed carries the HOST image only; a client sysext is the local + // `packaging/arch/build-sysext.sh` wrapper, which has no feed to update from. + (InstallKind::Sysext, Product::Host) => "sudo punktfunk-sysext update".into(), + (InstallKind::Sysext, Product::Client) => { + "rebuild the client sysext: bash packaging/arch/build-sysext.sh \ + && sudo cp punktfunk-client.raw /var/lib/extensions/ && sudo systemd-sysext refresh" + .into() + } + (InstallKind::RpmOstree, Product::Host) => { + "sudo /usr/share/punktfunk/update-punktfunk.sh (staged; reboot to finish)".into() + } + (InstallKind::RpmOstree, Product::Client) => { + format!("sudo rpm-ostree update --uninstall {bin} --install {bin} (staged; reboot to finish)") + } + (InstallKind::Apt, _) => { + format!("sudo apt update && sudo apt install --only-upgrade {bin}") + } + (InstallKind::Dnf, Product::Host) => "sudo dnf upgrade punktfunk".into(), + (InstallKind::Dnf, Product::Client) => "sudo dnf upgrade punktfunk-client".into(), + (InstallKind::Pacman, _) => "sudo pacman -Syu".into(), + (InstallKind::SteamosSource, _) => { + "bash ~/punktfunk/scripts/steamdeck/update.sh --pull".into() + } + (InstallKind::Nix, _) => "nix flake update punktfunk (then rebuild your system)".into(), + (InstallKind::Source, Product::Host) => { + "git pull && cargo build --release -p punktfunk-host".into() + } + (InstallKind::Source, Product::Client) => { + "git pull && cargo build --release -p punktfunk-client-linux".into() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn probe(exe: &str) -> Probe { + Probe { + windows: false, + exe: PathBuf::from(exe), + home: Some(PathBuf::from("/home/deck")), + ..Default::default() + } + } + + fn host_probe() -> Probe { + probe("/usr/bin/punktfunk-host") + } + + #[test] + fn ladder_sysext_beats_marker() { + let mut p = host_probe(); + p.sysext = true; + p.marker = Some("dnf canary".into()); + p.sysext_conf = Some("CHANNEL=canary\n".into()); + assert_eq!( + classify(&p, Product::Host), + (InstallKind::Sysext, Channel::Canary) + ); + p.sysext_conf = None; + assert_eq!( + classify(&p, Product::Host), + (InstallKind::Sysext, Channel::Stable) + ); + } + + #[test] + fn ladder_nix_store_path() { + let mut p = host_probe(); + p.exe = PathBuf::from("/nix/store/abc123-punktfunk-host-0.22.2/bin/punktfunk-host"); + assert_eq!(classify(&p, Product::Host).0, InstallKind::Nix); + } + + #[test] + fn ladder_cargo_target_is_source_even_under_home() { + let mut p = host_probe(); + p.exe = PathBuf::from("/home/deck/punktfunk/target/release/punktfunk-host"); + assert_eq!(classify(&p, Product::Host).0, InstallKind::Source); + } + + #[test] + fn ladder_deck_build_is_steamos_source() { + let mut p = host_probe(); + p.exe = PathBuf::from("/home/deck/punktfunk/target-steamos/release/punktfunk-host"); + assert_eq!(classify(&p, Product::Host).0, InstallKind::SteamosSource); + } + + /// The same user-owned path means something else for the client: there is no on-device + /// client build script, so it must not claim the Deck source-rebuild leg. + #[test] + fn ladder_user_owned_client_is_plain_source() { + let mut p = probe("/home/deck/.local/bin/punktfunk-client"); + p.marker = None; + assert_eq!(classify(&p, Product::Client).0, InstallKind::Source); + } + + #[test] + fn ladder_markers() { + for (marker, ostree, kind, channel) in [ + ("apt stable", false, InstallKind::Apt, Channel::Stable), + ("apt canary", false, InstallKind::Apt, Channel::Canary), + ("dnf stable", false, InstallKind::Dnf, Channel::Stable), + ("dnf stable", true, InstallKind::RpmOstree, Channel::Stable), + ("pacman canary", false, InstallKind::Pacman, Channel::Canary), + ] { + let mut p = host_probe(); + p.marker = Some(marker.into()); + p.ostree_booted = ostree; + assert_eq!( + classify(&p, Product::Host), + (kind, channel), + "marker `{marker}`" + ); + } + } + + #[test] + fn ladder_unknown_marker_falls_through_to_source() { + let mut p = host_probe(); + p.marker = Some("snap stable".into()); + assert_eq!(classify(&p, Product::Host).0, InstallKind::Source); + } + + /// Inside the sandbox every /usr fact belongs to the flatpak runtime, not the box — so a + /// leftover host marker on the real system must not win. + #[test] + fn flatpak_wins_over_every_file_rung() { + let mut p = probe("/app/bin/punktfunk-client"); + p.flatpak = true; + p.sysext = true; + p.marker = Some("pacman canary".into()); + assert_eq!( + classify(&p, Product::Client), + (InstallKind::Flatpak, Channel::Stable) + ); + } + + #[test] + fn windows_installed_vs_loose_build() { + let mut p = Probe { + windows: true, + exe: PathBuf::from("C:\\Program Files\\Punktfunk\\punktfunk-host.exe"), + version: "0.23.10118".into(), + ..Default::default() + }; + assert_eq!( + classify(&p, Product::Host), + (InstallKind::WindowsInstaller, Channel::Canary) + ); + p.exe = PathBuf::from("C:\\src\\punktfunk\\target\\release\\punktfunk-host.exe"); + assert_eq!(classify(&p, Product::Host).0, InstallKind::Source); + } + + /// Command hints are user-facing copy — they must name the right package per product. + #[test] + fn hints_are_product_specific() { + assert!(update_command(InstallKind::Apt, Product::Client).contains("punktfunk-client")); + assert!(update_command(InstallKind::Apt, Product::Host).contains("punktfunk-host")); + assert!(update_command(InstallKind::Flatpak, Product::Client).contains("flatpak update")); + } +} diff --git a/crates/pf-update-check/src/feed.rs b/crates/pf-update-check/src/feed.rs new file mode 100644 index 00000000..a44bd84d --- /dev/null +++ b/crates/pf-update-check/src/feed.rs @@ -0,0 +1,101 @@ +//! Fetching the per-channel manifest + its detached signature. +//! +//! Blocking (`ureq`) on purpose: both consumers call it off a background thread, and a sync +//! client with bundled webpki roots avoids depending on a system cert store — which the Deck +//! is exactly the wrong box to depend on. +//! +//! The signature is verified over the FINAL response bytes, after redirects. Our registry +//! answers a file GET with a 303 to object storage; a check that verified the pre-redirect +//! body would be verifying a redirect stub (the sysext-feed lesson, `publish-sysext-feed.sh`). + +use crate::manifest::{self, Manifest, MAX_MANIFEST_BYTES}; +use crate::sig::PublicKey; +use std::time::Duration; + +/// Feed base — `//manifest.json` + `.sig`. +pub const DEFAULT_FEED_BASE: &str = + "https://git.unom.io/api/packages/unom/generic/punktfunk-update"; + +/// One fetch's wall-clock budget. +const FETCH_TIMEOUT: Duration = Duration::from_secs(15); + +/// The feed base, with a `PUNKTFUNK_UPDATE_FEED` override for tests and dev feeds. This is +/// operator config (an env var on the process), never request-time input; the `https://` (or +/// loopback) requirement keeps a stray value from silently downgrading the transport. +pub fn feed_base() -> String { + std::env::var("PUNKTFUNK_UPDATE_FEED") + .ok() + .filter(|s| s.starts_with("https://") || s.starts_with("http://127.0.0.1")) + .unwrap_or_else(|| DEFAULT_FEED_BASE.to_string()) +} + +/// Fetch + verify the channel manifest. `keys` are the caller's pinned Ed25519 keys; an empty +/// list is refused rather than treated as "skip verification". +pub fn fetch_manifest_blocking( + base: &str, + channel: &str, + keys: &[PublicKey], + user_agent: &str, +) -> Result { + if keys.is_empty() { + return Err("no update key is pinned in this build".into()); + } + let agent = ureq::AgentBuilder::new() + .timeout(FETCH_TIMEOUT) + .redirects(3) + .user_agent(user_agent) + .build(); + let url = format!("{base}/{channel}/manifest.json"); + let sig_url = format!("{url}.sig"); + + let body = read_capped(agent.get(&url).call().map_err(fetch_err)?)?; + let sig = read_capped(agent.get(&sig_url).call().map_err(fetch_err)?)?; + let sig_text = String::from_utf8(sig).map_err(|_| "signature file is not text".to_string())?; + + manifest::verify_and_parse(&body, &sig_text, keys, channel).map_err(|e| format!("{e:#}")) +} + +fn fetch_err(e: ureq::Error) -> String { + match e { + ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"), + other => format!("feed fetch failed: {other}"), + } +} + +fn read_capped(resp: ureq::Response) -> Result, String> { + use std::io::Read as _; + let mut buf = Vec::new(); + let mut reader = resp.into_reader().take(MAX_MANIFEST_BYTES as u64 + 1); + reader + .read_to_end(&mut buf) + .map_err(|e| format!("read failed: {e}"))?; + if buf.len() > MAX_MANIFEST_BYTES { + return Err("response exceeds the manifest size cap".into()); + } + Ok(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_keys_never_fetches() { + // Fail-closed before any network call: an empty pin list is a broken build, not a + // licence to trust whatever the feed serves. + let err = + fetch_manifest_blocking("https://127.0.0.1:1", "stable", &[], "test").unwrap_err(); + assert!(err.contains("no update key"), "{err}"); + } + + #[test] + fn feed_override_must_be_https_or_loopback() { + // Not a full env test (env is process-global and tests run in parallel) — just the + // predicate the override is filtered by. + let ok = |s: &str| s.starts_with("https://") || s.starts_with("http://127.0.0.1"); + assert!(ok("https://example.test/feed")); + assert!(ok("http://127.0.0.1:8080/feed")); + assert!(!ok("http://example.test/feed")); + assert!(!ok("file:///tmp/feed")); + } +} diff --git a/crates/pf-update-check/src/lib.rs b/crates/pf-update-check/src/lib.rs new file mode 100644 index 00000000..000d8c12 --- /dev/null +++ b/crates/pf-update-check/src/lib.rs @@ -0,0 +1,40 @@ +//! Shared update-**check** core for the punktfunk host and the Linux client. +//! +//! Both products answer the same question — *does a newer build exist for this box's +//! channel?* — from the same Ed25519-signed per-channel manifest. This crate owns the part +//! where being wrong is a security bug, so that it exists exactly once: +//! +//! * [`sig`] — detached Ed25519 verification against pinned keys. +//! * [`manifest`] — the signed document's schema and its fail-closed validation rules. +//! * [`feed`] — fetching the document (and verifying over the *final*, post-redirect bytes). +//! * [`version`] — channels, and the comparison that decides "newer" across packaging formats. +//! * [`detect`] — the install-kind ladder, parameterised by [`detect::Product`]. +//! +//! There is deliberately **no apply code here**. Applying an update is privileged, per-product +//! and per-platform; it lives with the product that does it (`punktfunk-host::update`, +//! `pf-client-core::update`, and the root helper in `pf-update`). + +/// The Ed25519 public keys trusted for update manifests — two slots, so a key rotation is +/// "sign with the new one, ship builds trusting both, retire the old" (the plugin-store +/// `OFFICIAL_KEYS` drill) rather than a flag day. The private half is the +/// `UPDATE_MANIFEST_KEY` CI secret and the operator's offline backup. +/// +/// It lives here, not in either binary, because the host and the client consume the SAME +/// signed manifest: two pin lists could disagree about who may announce a release, and the +/// one that drifted would be the one nobody noticed. `scripts/ci/publish-update-manifest.sh` +/// cross-checks the signing key against this file before it signs anything. +pub const OFFICIAL_UPDATE_KEYS: [&str; 2] = [ + "ed25519:6rmlLg1aQ55cgB6icpC5BEpbMJxwPKdGaDQtDcJ0yLI=", + "", // rotation slot +]; + +pub mod detect; +pub mod feed; +pub mod manifest; +pub mod sig; +pub mod version; + +pub use detect::{InstallKind, Product}; +pub use manifest::{Manifest, MAX_MANIFEST_BYTES, SCHEMA}; +pub use sig::{verify_signature, PublicKey}; +pub use version::{canary_run, is_newer, triple, Channel}; diff --git a/crates/punktfunk-host/src/update/manifest.rs b/crates/pf-update-check/src/manifest.rs similarity index 81% rename from crates/punktfunk-host/src/update/manifest.rs rename to crates/pf-update-check/src/manifest.rs index 92b3fc08..89087506 100644 --- a/crates/punktfunk-host/src/update/manifest.rs +++ b/crates/pf-update-check/src/manifest.rs @@ -1,10 +1,14 @@ -//! The signed **update manifest**: the check truth for "a newer host exists" +//! The signed **update manifest**: the check truth for "a newer build exists" //! (design `host-update-from-web-console.md` §3). //! -//! One small JSON document per channel, Ed25519-signed with keys pinned in this binary -//! ([`super::UPDATE_KEYS`]) and verified by the exact code path the plugin store already -//! trusts ([`crate::store::index::verify_signature`]). TLS and the registry that serves the -//! document are transport, never trust. +//! One small JSON document per channel, Ed25519-signed with keys pinned in the consuming +//! binary and verified by the exact code path the plugin store already trusts ([`crate::sig`]). +//! TLS and the registry that serves the document are transport, never trust. +//! +//! The document describes a *release*, not a product: the host and the Linux client ship from +//! one repo at one version, so both compare their own installed version against the same +//! `version`/`ci_run` here. Only the per-product payload legs (today: `windows_host`) are +//! product-specific, and a consumer that doesn't need one simply ignores it. //! //! Rules, all fail-closed (the sysext 303 lesson encoded): //! 1. **Signature before parse** — over the exact fetched bytes, then strict JSON. An HTML @@ -12,27 +16,27 @@ //! 2. **Channel binding** — the document names its channel and it must match the one we //! asked for, so a validly-signed canary manifest replayed onto the stable URL is refused. //! 3. **Monotonic serial** — the publish-time serial can never go backwards for a channel -//! (the anti-downgrade/anti-replay floor, persisted by [`super`]). -//! 4. **Pinned notes origin** — the release-notes link the console renders must live on our -//! forge, so a signed-but-wrong document can't send the operator to a lookalike page. +//! (the anti-downgrade/anti-replay floor, persisted by the consumer). +//! 4. **Pinned notes origin** — the release-notes link a UI renders must live on our forge, +//! so a signed-but-wrong document can't send the operator to a lookalike page. -use crate::store::index::{verify_signature, PublicKey}; +use crate::sig::{verify_signature, PublicKey}; use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; -/// The only manifest schema this host understands. A breaking change bumps this; old hosts +/// The only manifest schema this build understands. A breaking change bumps this; old builds /// report "unsupported schema" instead of guessing. -pub(crate) const SCHEMA: u32 = 1; +pub const SCHEMA: u32 = 1; /// Hard cap on a fetched manifest (and its signature). The real document is <1 KB. -pub(crate) const MAX_MANIFEST_BYTES: usize = 64 * 1024; +pub const MAX_MANIFEST_BYTES: usize = 64 * 1024; /// The only origin a manifest may point the operator at for release notes. const NOTES_ORIGIN: &str = "https://git.unom.io/"; /// The signed update manifest, as served (and signed) per channel. #[derive(Debug, Clone, Deserialize, Serialize)] -pub(crate) struct Manifest { +pub struct Manifest { /// Document schema — must equal [`SCHEMA`]. pub schema: u32, /// The channel this document was published for (`stable` | `canary`). Bound-checked @@ -44,24 +48,24 @@ pub(crate) struct Manifest { /// RFC-3339 publish time. Display only. #[serde(default)] pub published_at: String, - /// The released host version this manifest announces. + /// The released version this manifest announces. pub version: String, - /// Release-notes link the console renders. Must be on [`NOTES_ORIGIN`]. + /// Release-notes link a UI renders. Must be on [`NOTES_ORIGIN`]. #[serde(default)] pub notes_url: String, /// Canary only: the CI run number, the definitive "newer" axis where per-channel version /// strings differ (`~ciN`, `0.ciN`, a padded pkgrel, `M.m.run`). #[serde(default)] pub ci_run: Option, - /// The Windows installer leg (design §6) — parsed and carried now so a U0 host is already - /// schema-complete, consumed by the U1 apply path. + /// The Windows installer leg (design §6) — consumed by the host's Windows apply path and + /// ignored everywhere else. #[serde(default)] pub windows_host: Option, } /// Where the Windows host installer for [`Manifest::version`] lives and how to verify it. #[derive(Debug, Clone, Deserialize, Serialize)] -pub(crate) struct WindowsHostAsset { +pub struct WindowsHostAsset { /// Immutable per-version download URL — never a mutable `latest/` alias, so the hash /// below can't race an alias re-upload. pub url: String, @@ -79,7 +83,7 @@ pub(crate) struct WindowsHostAsset { /// Verify `sig_text` over the exact `bytes` against `keys`, then strictly parse and validate /// the document for `expected_channel`. The only constructor — there is no unsigned path. -pub(crate) fn verify_and_parse( +pub fn verify_and_parse( bytes: &[u8], sig_text: &str, keys: &[PublicKey], @@ -91,20 +95,20 @@ pub(crate) fn verify_and_parse( /// Parse + validate a document whose signature has already been checked. Split out so tests /// can exercise validation without minting signatures for every case. -pub(crate) fn parse_verified(bytes: &[u8], expected_channel: &str) -> Result { +pub fn parse_verified(bytes: &[u8], expected_channel: &str) -> Result { if bytes.len() > MAX_MANIFEST_BYTES { bail!("manifest is larger than the {MAX_MANIFEST_BYTES}-byte cap"); } let m: Manifest = serde_json::from_slice(bytes).context("update manifest is not valid JSON")?; if m.schema != SCHEMA { bail!( - "unsupported manifest schema {} (this host understands {SCHEMA})", + "unsupported manifest schema {} (this build understands {SCHEMA})", m.schema ); } if m.channel != expected_channel { bail!( - "manifest is for channel `{}` but this host asked for `{expected_channel}`", + "manifest is for channel `{}` but this build asked for `{expected_channel}`", m.channel ); } @@ -159,14 +163,7 @@ mod tests { #[test] fn signed_roundtrip_and_tamper() { use base64::Engine as _; - use ring::signature::KeyPair as _; - let rng = ring::rand::SystemRandom::new(); - let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); - let key_str = format!( - "ed25519:{}", - base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref()) - ); + let (key_str, kp) = crate::sig::tests::keypair(); let keys = vec![PublicKey::parse(&key_str).unwrap()]; let body = bytes(&doc()); diff --git a/crates/pf-update-check/src/sig.rs b/crates/pf-update-check/src/sig.rs new file mode 100644 index 00000000..ce8a965d --- /dev/null +++ b/crates/pf-update-check/src/sig.rs @@ -0,0 +1,106 @@ +//! Detached Ed25519 signatures over an exact document (plugin-store index, update manifest). +//! +//! Moved here from `punktfunk-host::store::index` when the client grew its own update check: +//! one implementation, one set of tests, one place to fix if the format ever moves. The host +//! re-exports it, so its call sites are unchanged. + +use anyhow::{bail, Context, Result}; + +/// An ed25519 public key pinned by a source record, spelled `ed25519:`. +#[derive(Debug, Clone)] +pub struct PublicKey(Vec); + +impl PublicKey { + pub fn parse(s: &str) -> Result { + use base64::Engine as _; + let b64 = s + .strip_prefix("ed25519:") + .context("public key must be spelled `ed25519:`")?; + let raw = base64::engine::general_purpose::STANDARD + .decode(b64.trim()) + .context("public key is not valid base64")?; + if raw.len() != 32 { + bail!("ed25519 public key must be 32 bytes, got {}", raw.len()); + } + Ok(Self(raw)) + } +} + +/// Verify a detached ed25519 signature over the **exact** document bytes against any of the +/// pinned keys (two slots, so a key rotation is "sign with the new one, ship a build that +/// trusts both, retire the old" rather than a flag day). +/// +/// `sig_text` is the `.sig` file's contents: base64, whitespace-tolerant. +pub fn verify_signature(bytes: &[u8], sig_text: &str, keys: &[PublicKey]) -> Result<()> { + use base64::Engine as _; + if keys.is_empty() { + bail!("no public key pinned for this source"); + } + let sig = base64::engine::general_purpose::STANDARD + .decode(sig_text.trim()) + .context("signature file is not valid base64")?; + for key in keys { + let pk = ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, &key.0); + if pk.verify(bytes, &sig).is_ok() { + return Ok(()); + } + } + bail!("signature does not verify against any pinned key") +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + /// A fresh ring keypair as `(pinned key string, signer)` — the format contract with the + /// CI signers (raw 32-byte key, `ed25519:`; raw 64-byte signature, base64). + pub(crate) fn keypair() -> (String, ring::signature::Ed25519KeyPair) { + use base64::Engine as _; + use ring::signature::KeyPair as _; + let rng = ring::rand::SystemRandom::new(); + let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); + let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let key_str = format!( + "ed25519:{}", + base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref()) + ); + (key_str, kp) + } + + #[test] + fn roundtrip_and_tamper() { + use base64::Engine as _; + let (key_str, kp) = keypair(); + let keys = vec![PublicKey::parse(&key_str).unwrap()]; + let body = b"the exact bytes"; + let sig = base64::engine::general_purpose::STANDARD.encode(kp.sign(body)); + + assert!(verify_signature(body, &sig, &keys).is_ok()); + // A `.sig` file written by a shell redirect ends in a newline — tolerated. + assert!(verify_signature(body, &format!("{sig}\n"), &keys).is_ok()); + assert!(verify_signature(b"other bytes", &sig, &keys).is_err()); + // No pinned key at all fails closed rather than skipping verification. + assert!(verify_signature(body, &sig, &[]).is_err()); + } + + #[test] + fn key_format_is_enforced() { + assert!(PublicKey::parse("6rmlLg1aQ55cgB6icpC5BEpbMJxwPKdGaDQtDcJ0yLI=").is_err()); + assert!(PublicKey::parse("ed25519:not base64!!").is_err()); + // Right encoding, wrong length. + assert!(PublicKey::parse("ed25519:AAAA").is_err()); + } + + /// A signature from a key we do NOT pin must fail even though it is perfectly valid — + /// "verifies" and "verifies as us" are the same statement or the pinning is decorative. + #[test] + fn other_signer_refused() { + use base64::Engine as _; + let (ours, _) = keypair(); + let (_, theirs) = keypair(); + let keys = vec![PublicKey::parse(&ours).unwrap()]; + let body = b"the exact bytes"; + let sig = base64::engine::general_purpose::STANDARD.encode(theirs.sign(body)); + assert!(verify_signature(body, &sig, &keys).is_err()); + } +} diff --git a/crates/pf-update-check/src/version.rs b/crates/pf-update-check/src/version.rs new file mode 100644 index 00000000..1ffb4894 --- /dev/null +++ b/crates/pf-update-check/src/version.rs @@ -0,0 +1,186 @@ +//! Release channels and the "is the manifest newer than me?" comparison. +//! +//! The hard part is canary: every packaging channel spells the same CI build differently +//! (`0.23.0~ci10250.gab12cd34` on deb, `0.23.0-0.ci10250.g…` on rpm, a zero-padded pkgrel on +//! pacman, `0.23.10250` on Windows/decky). Comparing those strings to each other is +//! meaningless, so canary compares `(major, minor)` and then the **CI run number**, which is +//! the one monotonic axis every channel carries. Stable compares the plain triple. +//! +//! Everything here is definitive-or-false: an unparseable pair never flags an update. A UI +//! still shows both version strings — the badge just doesn't light up on guesswork. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Channel { + Stable, + Canary, +} + +impl Channel { + pub fn as_str(self) -> &'static str { + match self { + Channel::Stable => "stable", + Channel::Canary => "canary", + } + } +} + +/// Leading `major.minor.patch` of a version string, ignoring any suffix (`~ci…`, `-1`, `+…`). +pub fn triple(v: &str) -> Option<(u64, u64, u64)> { + let mut parts = v + .split(|c: char| !c.is_ascii_digit()) + .filter(|s| !s.is_empty()); + // Split on any non-digit: "0.23.0~ci10250.gab" → 0,23,0,10250… — take the first three + // ONLY if the string actually starts with digits (else it's not a version at all). + if !v.starts_with(|c: char| c.is_ascii_digit()) { + return None; + } + Some(( + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + )) +} + +/// The CI run number embedded in a canary version string, wherever the channel's format hid +/// it: `0.23.0~ci10250.g` (deb), `0.23.0-0.ci10250.g` (rpm), `0.23.10250` +/// (Windows/decky style, run-as-patch). A stable string yields `None`. +pub fn canary_run(version: &str) -> Option { + // `ci` immediately followed by digits, anywhere. + let mut rest = version; + while let Some(pos) = rest.find("ci") { + let digits: String = rest[pos + 2..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + if !digits.is_empty() { + return digits.parse().ok(); + } + rest = &rest[pos + 2..]; + } + match triple(version) { + Some((_, _, patch)) if patch >= 1000 => Some(patch), + _ => None, + } +} + +/// Is the manifest's release newer than `current`? See the module docs for why canary +/// compares run numbers rather than patch fields. +pub fn is_newer( + manifest_version: &str, + manifest_ci_run: Option, + current: &str, + channel: Channel, +) -> bool { + let (Some(m), Some(c)) = (triple(manifest_version), triple(current)) else { + return false; + }; + match channel { + Channel::Stable => m > c, + Channel::Canary => { + if (m.0, m.1) != (c.0, c.1) { + return (m.0, m.1) > (c.0, c.1); + } + let manifest_run = manifest_ci_run.or_else(|| canary_run(manifest_version)); + match (manifest_run, canary_run(current)) { + (Some(mr), Some(cr)) => mr > cr, + _ => false, + } + } + } +} + +/// Windows canary installers are versioned `M.m.` where `` is a 4+ digit CI run +/// number; stable patch numbers stay small. Heuristic, documented in the plan (R10). +pub fn windows_channel_of(version: &str) -> Channel { + match triple(version) { + Some((_, _, patch)) if patch >= 1000 => Channel::Canary, + _ => Channel::Stable, + } +} + +/// `CHANNEL=canary` in a shell-style conf (the sysext updater's own format). +pub fn conf_channel(conf: &str) -> Option { + for line in conf.lines() { + if let Some(v) = line.trim().strip_prefix("CHANNEL=") { + return Some(match v.trim() { + "canary" => Channel::Canary, + _ => Channel::Stable, + }); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn triples() { + assert_eq!(triple("0.23.0"), Some((0, 23, 0))); + assert_eq!(triple("0.23.0~ci10250.gab12cd34"), Some((0, 23, 0))); + assert_eq!(triple("0.23.10250"), Some((0, 23, 10250))); + assert_eq!(triple("garbage"), None); + assert_eq!(triple("1.2"), None); + } + + #[test] + fn canary_runs() { + assert_eq!(canary_run("0.23.0~ci10250.gab12cd34"), Some(10250)); + assert_eq!(canary_run("0.23.0-0.ci777.g12345678"), Some(777)); + assert_eq!(canary_run("0.23.10250"), Some(10250)); // run-as-patch (Windows/decky) + assert_eq!(canary_run("0.23.0"), None); // stable string + assert_eq!(canary_run("0.23.0-1"), None); + } + + #[test] + fn newer_stable() { + assert!(is_newer("0.23.0", None, "0.22.2", Channel::Stable)); + assert!(!is_newer("0.22.2", None, "0.22.2", Channel::Stable)); + assert!(!is_newer("0.22.1", None, "0.22.2", Channel::Stable)); // downgrade never flags + assert!(!is_newer("not-a-version", None, "0.22.2", Channel::Stable)); + } + + #[test] + fn newer_canary_compares_runs_not_patch() { + // deb canary current vs Windows-style manifest version, same run ⇒ NOT newer, + // even though a naive triple compare says 10250 > 0. + assert!(!is_newer( + "0.23.10250", + Some(10250), + "0.23.0~ci10250.gab12cd34", + Channel::Canary + )); + assert!(is_newer( + "0.23.10251", + Some(10251), + "0.23.0~ci10250.gab12cd34", + Channel::Canary + )); + // Minor bump wins outright. + assert!(is_newer( + "0.24.100", + Some(100), + "0.23.0~ci10250.g12", + Channel::Canary + )); + // No run extractable on either side ⇒ conservative false. + assert!(!is_newer("0.23.10250", None, "0.23.0", Channel::Canary)); + } + + #[test] + fn windows_channel_heuristic() { + assert_eq!(windows_channel_of("0.22.2"), Channel::Stable); + assert_eq!(windows_channel_of("0.23.10118"), Channel::Canary); + } + + #[test] + fn conf_channels() { + assert_eq!(conf_channel("CHANNEL=canary\n"), Some(Channel::Canary)); + assert_eq!( + conf_channel("# a comment\nCHANNEL=stable"), + Some(Channel::Stable) + ); + assert_eq!(conf_channel("KEEP=6\n"), None); + } +} diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index b4ff8d4e..c3a86327 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -10,6 +10,8 @@ repository.workspace = true [dependencies] punktfunk-core = { path = "../punktfunk-core", features = ["quic"] } +# Signed update-manifest fetch/verify + install-kind detection, shared with the Linux client. +pf-update-check = { path = "../pf-update-check" } # Config-dir + owner-private file helpers (moved out of the gamestream junk drawer, plan §W6). pf-paths = { path = "../pf-paths" } # Process-wide host config global, extracted to a leaf crate (plan §W6). diff --git a/crates/punktfunk-host/src/mgmt/update.rs b/crates/punktfunk-host/src/mgmt/update.rs index fb552045..ba263af9 100644 --- a/crates/punktfunk-host/src/mgmt/update.rs +++ b/crates/punktfunk-host/src/mgmt/update.rs @@ -130,7 +130,7 @@ fn status_from(snap: update::Snapshot) -> UpdateStatus { channel: channel.as_str().into(), current_version: current.into(), apply: update::apply_support().into(), - channel_hint: detect::channel_hint(kind).into(), + channel_hint: detect::channel_hint(kind), check_disabled: update::check_disabled(), available, manifest: snap.checked.as_ref().map(|c| UpdateManifestInfo { diff --git a/crates/punktfunk-host/src/store/index.rs b/crates/punktfunk-host/src/store/index.rs index 9dfa41bc..cecea4c2 100644 --- a/crates/punktfunk-host/src/store/index.rs +++ b/crates/punktfunk-host/src/store/index.rs @@ -121,47 +121,11 @@ pub(crate) struct Advisory { // ---------------------------------------------------------------- signature -/// An ed25519 public key pinned by a source record, spelled `ed25519:`. -#[derive(Debug, Clone)] -pub(crate) struct PublicKey(Vec); - -impl PublicKey { - pub(crate) fn parse(s: &str) -> Result { - use base64::Engine as _; - let b64 = s - .strip_prefix("ed25519:") - .context("public key must be spelled `ed25519:`")?; - let raw = base64::engine::general_purpose::STANDARD - .decode(b64.trim()) - .context("public key is not valid base64")?; - if raw.len() != 32 { - bail!("ed25519 public key must be 32 bytes, got {}", raw.len()); - } - Ok(Self(raw)) - } -} - -/// Verify a detached ed25519 signature over the **exact** index bytes against any of the pinned -/// keys (two slots, so a key rotation is "sign with the new one, ship a host that trusts both, -/// retire the old" rather than a flag day). -/// -/// `sig_text` is the `.sig` file's contents: base64, whitespace-tolerant. -pub(crate) fn verify_signature(bytes: &[u8], sig_text: &str, keys: &[PublicKey]) -> Result<()> { - use base64::Engine as _; - if keys.is_empty() { - bail!("no public key pinned for this source"); - } - let sig = base64::engine::general_purpose::STANDARD - .decode(sig_text.trim()) - .context("signature file is not valid base64")?; - for key in keys { - let pk = ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, &key.0); - if pk.verify(bytes, &sig).is_ok() { - return Ok(()); - } - } - bail!("index signature does not verify against any pinned key") -} +// Ed25519 verification moved to `pf-update-check` when the Linux client grew its own update +// check: the host's plugin-store index and both products' update manifests are all "verify a +// detached signature over exact bytes against pinned keys", and that rule must exist once. +// Re-exported under the old path so every call site here is unchanged. +pub(crate) use pf_update_check::sig::{verify_signature, PublicKey}; // ---------------------------------------------------------------- parse + validate @@ -511,56 +475,6 @@ mod tests { assert!(!valid_integrity("sha512")); } - #[test] - fn signature_verifies_only_over_exact_bytes_and_pinned_keys() { - use ring::signature::KeyPair as _; - let rng = ring::rand::SystemRandom::new(); - let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); - let key = { - use base64::Engine as _; - PublicKey::parse(&format!( - "ed25519:{}", - base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref()) - )) - .unwrap() - }; - let body = br#"{"schema":1,"plugins":[]}"#; - let sig = { - use base64::Engine as _; - base64::engine::general_purpose::STANDARD.encode(kp.sign(body).as_ref()) - }; - - assert!(verify_signature(body, &sig, std::slice::from_ref(&key)).is_ok()); - // whitespace in the .sig file is tolerated - assert!(verify_signature(body, &format!("{sig}\n"), std::slice::from_ref(&key)).is_ok()); - // one byte of tampering fails - assert!(verify_signature( - br#"{"schema":1,"plugins":[ ]}"#, - &sig, - std::slice::from_ref(&key) - ) - .is_err()); - // a different key fails - let other = ring::signature::Ed25519KeyPair::from_pkcs8( - ring::signature::Ed25519KeyPair::generate_pkcs8(&rng) - .unwrap() - .as_ref(), - ) - .unwrap(); - let other_key = { - use base64::Engine as _; - PublicKey::parse(&format!( - "ed25519:{}", - base64::engine::general_purpose::STANDARD.encode(other.public_key().as_ref()) - )) - .unwrap() - }; - assert!(verify_signature(body, &sig, &[other_key]).is_err()); - // no pinned key at all fails closed - assert!(verify_signature(body, &sig, &[]).is_err()); - } - /// The real published index must parse here, field for field. /// /// This fixture is a snapshot of `unom/punktfunk-plugin-index`'s `v1/index.json`. The index diff --git a/crates/punktfunk-host/src/update.rs b/crates/punktfunk-host/src/update.rs index db48026a..86398fd9 100644 --- a/crates/punktfunk-host/src/update.rs +++ b/crates/punktfunk-host/src/update.rs @@ -20,28 +20,22 @@ pub(crate) mod detect; pub(crate) mod jobs; #[cfg(target_os = "linux")] mod linux; -pub(crate) mod manifest; +// The signed manifest's schema + validation live in `pf-update-check` (shared with the +// client's check). Re-exported so `manifest::…` call sites below are unchanged. +pub(crate) use pf_update_check::manifest; #[cfg(target_os = "windows")] pub(crate) mod windows; -use crate::store::index::PublicKey; use manifest::Manifest; +use pf_update_check::PublicKey; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -/// The Ed25519 public keys this binary trusts for update manifests — two slots so a key -/// rotation is "sign with the new one, ship a host trusting both, retire the old" (the -/// plugin-store `OFFICIAL_KEYS` drill). The private half is the `UPDATE_MANIFEST_KEY` CI -/// secret; it also lives in the operator's offline backup (plan U0.1 DoD). -pub(crate) const UPDATE_KEYS: [&str; 2] = [ - "ed25519:6rmlLg1aQ55cgB6icpC5BEpbMJxwPKdGaDQtDcJ0yLI=", - "", // rotation slot -]; - -/// Feed base — `//manifest.json` + `.sig`. Override for tests/dev feeds via -/// `PUNKTFUNK_UPDATE_FEED` (a base URL, not request-time input: env is operator config). -const DEFAULT_FEED_BASE: &str = "https://git.unom.io/api/packages/unom/generic/punktfunk-update"; +/// The Ed25519 public keys this binary trusts for update manifests. The list itself lives in +/// `pf-update-check` (the client verifies the same manifest and must trust the same signers); +/// this alias keeps the host's call sites and the plan's vocabulary intact. +pub(crate) use pf_update_check::OFFICIAL_UPDATE_KEYS as UPDATE_KEYS; /// A cache older than this is refreshed in the background on the next status read. const AUTO_REFRESH_AFTER: Duration = Duration::from_secs(6 * 60 * 60); @@ -53,9 +47,6 @@ pub(crate) const FORCE_MIN_INTERVAL: Duration = Duration::from_secs(30); /// freeze-detection hint (design §3.2), not an error. const STALE_AFTER: Duration = Duration::from_secs(45 * 24 * 60 * 60); -/// One fetch's wall-clock budget (mirrors the store catalog fetch). -const FETCH_TIMEOUT: Duration = Duration::from_secs(15); - /// Update checks disabled by operator config (env or `host.env`). pub(crate) fn check_disabled() -> bool { matches!( @@ -129,13 +120,6 @@ pub(crate) fn opt_in_hint() -> Option { None } -fn feed_base() -> String { - std::env::var("PUNKTFUNK_UPDATE_FEED") - .ok() - .filter(|s| s.starts_with("https://") || s.starts_with("http://127.0.0.1")) - .unwrap_or_else(|| DEFAULT_FEED_BASE.to_string()) -} - fn pinned_keys() -> Vec { UPDATE_KEYS .iter() @@ -228,55 +212,18 @@ fn store_floor(path: &Path, channel: &str, serial: u64) { // ---------------------------------------------------------------- refresh -/// Fetch + verify the channel manifest. Blocking (`ureq`) — call from a blocking thread. +/// Fetch + verify the channel manifest through the shared checker. Blocking — call from a +/// blocking thread. fn fetch_manifest_blocking(channel: &str) -> Result { - let agent = ureq::AgentBuilder::new() - .timeout(FETCH_TIMEOUT) - // Follow the registry's 303-to-object-storage redirect; the signature is verified - // over the FINAL bytes (the sysext-feed lesson). - .redirects(3) - .user_agent(&format!( + pf_update_check::feed::fetch_manifest_blocking( + &pf_update_check::feed::feed_base(), + channel, + &pinned_keys(), + &format!( "punktfunk-host/{} (update-check)", env!("PUNKTFUNK_VERSION") - )) - .build(); - let base = feed_base(); - let url = format!("{base}/{channel}/manifest.json"); - let sig_url = format!("{url}.sig"); - - let body = read_capped(agent.get(&url).call().map_err(fetch_err)?)?; - let sig = read_capped(agent.get(&sig_url).call().map_err(fetch_err)?)?; - let sig_text = String::from_utf8(sig).map_err(|_| "signature file is not text".to_string())?; - - let keys = pinned_keys(); - if keys.is_empty() { - // Both slots empty would mean a build with the feature disarmed; refuse rather than - // silently skipping verification. - return Err("no update key is pinned in this build".into()); - } - manifest::verify_and_parse(&body, &sig_text, &keys, channel).map_err(|e| format!("{e:#}")) -} - -fn fetch_err(e: ureq::Error) -> String { - match e { - ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"), - other => format!("feed fetch failed: {other}"), - } -} - -fn read_capped(resp: ureq::Response) -> Result, String> { - use std::io::Read as _; - let mut buf = Vec::new(); - let mut reader = resp - .into_reader() - .take(manifest::MAX_MANIFEST_BYTES as u64 + 1); - reader - .read_to_end(&mut buf) - .map_err(|e| format!("read failed: {e}"))?; - if buf.len() > manifest::MAX_MANIFEST_BYTES { - return Err("response exceeds the manifest size cap".into()); - } - Ok(buf) + ), + ) } /// One full refresh: fetch, verify, enforce + raise the serial floor, update the cache, diff --git a/crates/punktfunk-host/src/update/detect.rs b/crates/punktfunk-host/src/update/detect.rs index 7627c4a9..6fd94647 100644 --- a/crates/punktfunk-host/src/update/detect.rs +++ b/crates/punktfunk-host/src/update/detect.rs @@ -1,420 +1,65 @@ //! **How was this host installed, and on which channel?** (design §4.1) //! -//! The apply strategy — and, until apply lands, the command hint the console shows — hangs -//! off the install kind. Detection is a ladder over root-owned facts: packaging writes a -//! marker (`/usr/share/punktfunk/install-kind`, e.g. `apt stable`), the sysext self-identifies -//! via its merged extension-release, Nix by store path, and so on. The API only ever *reads* -//! this; nothing request-side can influence it. +//! The ladder itself, the version comparison and the per-kind command hints moved to +//! `pf-update-check` when the Linux client grew the same surface — the delivery channels are +//! the same ones, and two copies of "is this newer?" would have drifted. What stays here is +//! the host's binding of that shared machinery: [`Product::Host`], this binary's own version, +//! and the process-wide cache. //! -//! The ladder itself is a pure function over a [`Probe`] so every branch is unit-testable; -//! [`detect`] gathers the real probe once per process. +//! Everything is re-exported under the names the rest of the host already uses, so call sites +//! (`detect::InstallKind::Apt`, `detect::is_newer(…)`, …) are unchanged. -use std::path::{Path, PathBuf}; +// Only what the host actually names — an unused re-export is a warning, and CI runs +// `clippy -D warnings`. Tests reach for the rest through `pf_update_check::…` directly. +pub(crate) use pf_update_check::detect::InstallKind; +pub(crate) use pf_update_check::version::{is_newer, Channel}; + +use pf_update_check::detect::{classify as classify_shared, gather, Product}; use std::sync::OnceLock; -/// Where the Linux packages stamp how they were installed. First word = kind -/// (`apt`|`dnf`|`pacman`), optional second word = channel (`stable`|`canary`). -const MARKER_PATH: &str = "/usr/share/punktfunk/install-kind"; - -/// The merged sysext names itself here (written by `build-sysext.sh`); its presence means the -/// running `/usr` overlay came from the sysext image, regardless of any leftover marker. -const SYSEXT_MARKER: &str = "/usr/lib/extension-release.d/extension-release.punktfunk"; - -/// The sysext updater's own config (`CHANNEL=stable|canary`). -const SYSEXT_CONF: &str = "/etc/punktfunk-sysext.conf"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum InstallKind { - WindowsInstaller, - Sysext, - RpmOstree, - Apt, - Dnf, - Pacman, - SteamosSource, - Nix, - Source, -} - -impl InstallKind { - pub(crate) fn as_str(self) -> &'static str { - match self { - InstallKind::WindowsInstaller => "windows-installer", - InstallKind::Sysext => "sysext", - InstallKind::RpmOstree => "rpm-ostree", - InstallKind::Apt => "apt", - InstallKind::Dnf => "dnf", - InstallKind::Pacman => "pacman", - InstallKind::SteamosSource => "steamos-source", - InstallKind::Nix => "nix", - InstallKind::Source => "source", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum Channel { - Stable, - Canary, -} - -impl Channel { - pub(crate) fn as_str(self) -> &'static str { - match self { - Channel::Stable => "stable", - Channel::Canary => "canary", - } - } -} - -/// The root-owned facts the ladder reads, gathered once by [`gather`] (tests build these -/// directly). -#[derive(Debug, Default)] -pub(crate) struct Probe { - /// Running on Windows (cfg, not a file). - pub windows: bool, - /// The running exe's path. - pub exe: PathBuf, - /// `$HOME`, if any. - pub home: Option, - /// Contents of [`MARKER_PATH`], if present. - pub marker: Option, - /// [`SYSEXT_MARKER`] exists (merged sysext overlay). - pub sysext: bool, - /// Contents of [`SYSEXT_CONF`], if present. - pub sysext_conf: Option, - /// `/run/ostree-booted` exists (rpm-ostree / bootc family). - pub ostree_booted: bool, -} - -fn gather() -> Probe { - Probe { - windows: cfg!(target_os = "windows"), - exe: std::env::current_exe().unwrap_or_default(), - home: std::env::var_os("HOME").map(PathBuf::from), - marker: std::fs::read_to_string(MARKER_PATH).ok(), - sysext: Path::new(SYSEXT_MARKER).exists(), - sysext_conf: std::fs::read_to_string(SYSEXT_CONF).ok(), - ostree_booted: Path::new("/run/ostree-booted").exists(), - } -} - -/// The ladder (design §4.1). Order matters and each rung is a root-owned fact: -/// sysext overlay > Nix store path > dev/source tree > user-owned Deck build > package -/// marker (flipped to rpm-ostree when the box is ostree-booted) > `source` fallback. -pub(crate) fn classify(p: &Probe) -> (InstallKind, Channel) { - if p.windows { - // The installer is the only supported Windows delivery; a loose cargo build shows - // itself by not living under Program Files. Channel: canary installers carry the CI - // run as the third component (`M.m.`), see `windows_channel_of`. - let installed = p - .exe - .to_string_lossy() - .to_ascii_lowercase() - .contains("\\program files\\punktfunk"); - return if installed { - ( - InstallKind::WindowsInstaller, - windows_channel_of(env!("PUNKTFUNK_VERSION")), - ) - } else { - (InstallKind::Source, Channel::Stable) - }; - } - - if p.sysext { - let channel = p - .sysext_conf - .as_deref() - .and_then(conf_channel) - .unwrap_or(Channel::Stable); - return (InstallKind::Sysext, channel); - } - - if p.exe.starts_with("/nix/store") { - return (InstallKind::Nix, Channel::Stable); - } - - // A cargo tree anywhere (CI, dev box, the Deck checkout mid-build) is `source`; the - // Deck's install script runs the binary out of `~/punktfunk/target-steamos/`, which is - // user-owned but NOT a plain `target/` dir — that distinction is the marker here. - let exe_str = p.exe.to_string_lossy().to_string(); - if exe_str.contains("/target/") { - return (InstallKind::Source, Channel::Stable); - } - if let Some(home) = &p.home { - if p.exe.starts_with(home) { - return (InstallKind::SteamosSource, Channel::Canary); - } - } - - if let Some(marker) = &p.marker { - let mut words = marker.split_whitespace(); - let kind = words.next().unwrap_or(""); - let channel = match words.next() { - Some("canary") => Channel::Canary, - _ => Channel::Stable, - }; - let kind = match kind { - "apt" => Some(InstallKind::Apt), - // An ostree-booted box consumed the RPM by layering (or an image build); either - // way `dnf upgrade` is not how it updates. bootc-vs-layered is refined in U2 via - // `rpm-ostree status` — until then both report `rpm-ostree` (notify text is - // identical in U0). - "dnf" if p.ostree_booted => Some(InstallKind::RpmOstree), - "dnf" => Some(InstallKind::Dnf), - "pacman" => Some(InstallKind::Pacman), - _ => None, - }; - if let Some(kind) = kind { - return (kind, channel); - } - } - - (InstallKind::Source, Channel::Stable) -} - -/// `CHANNEL=canary` in `/etc/punktfunk-sysext.conf` (the sysext updater's own format). -fn conf_channel(conf: &str) -> Option { - for line in conf.lines() { - if let Some(v) = line.trim().strip_prefix("CHANNEL=") { - return Some(match v.trim() { - "canary" => Channel::Canary, - _ => Channel::Stable, - }); - } - } - None -} - -/// Windows canary installers are versioned `M.m.` where `` is a 4+ digit CI run -/// number; stable patch numbers stay small. Heuristic, documented in the plan (R10). -fn windows_channel_of(version: &str) -> Channel { - match triple(version) { - Some((_, _, patch)) if patch >= 1000 => Channel::Canary, - _ => Channel::Stable, - } -} - /// The process-wide answer, computed once. pub(crate) fn detect() -> (InstallKind, Channel) { static DETECTED: OnceLock<(InstallKind, Channel)> = OnceLock::new(); - *DETECTED.get_or_init(|| classify(&gather())) + *DETECTED.get_or_init(|| { + classify_shared( + &gather(Product::Host, env!("PUNKTFUNK_VERSION")), + Product::Host, + ) + }) } -// ---------------------------------------------------------------- version comparison - -/// Leading `major.minor.patch` of a version string, ignoring any suffix (`~ci…`, `-1`, `+…`). -pub(crate) fn triple(v: &str) -> Option<(u64, u64, u64)> { - let mut parts = v - .split(|c: char| !c.is_ascii_digit()) - .filter(|s| !s.is_empty()); - // Split on any non-digit: "0.23.0~ci10250.gab" → 0,23,0,10250… — take the first three - // ONLY if the string actually starts with digits (else it's not a version at all). - if !v.starts_with(|c: char| c.is_ascii_digit()) { - return None; - } - Some(( - parts.next()?.parse().ok()?, - parts.next()?.parse().ok()?, - parts.next()?.parse().ok()?, - )) -} - -/// The CI run number embedded in a canary version string, wherever the channel's format hid -/// it: `0.23.0~ci10250.g` (deb), `0.23.0-0.ci10250.g` (rpm), `0.23.10250` -/// (Windows/decky style, run-as-patch). A stable string yields `None`. -pub(crate) fn canary_run(version: &str) -> Option { - // `ci` immediately followed by digits, anywhere. - let mut rest = version; - while let Some(pos) = rest.find("ci") { - let digits: String = rest[pos + 2..] - .chars() - .take_while(|c| c.is_ascii_digit()) - .collect(); - if !digits.is_empty() { - return digits.parse().ok(); - } - rest = &rest[pos + 2..]; - } - match triple(version) { - Some((_, _, patch)) if patch >= 1000 => Some(patch), - _ => None, - } -} - -/// Is the manifest's release newer than what this process runs? Definitive-or-false: an -/// unparseable pair never flags (the console still shows both version strings — the badge -/// just doesn't light up on guesswork). Canary compares `(major, minor)` then the CI run, -/// because canary patch fields mean different things per channel (R10). -pub(crate) fn is_newer( - manifest_version: &str, - manifest_ci_run: Option, - current: &str, - channel: Channel, -) -> bool { - let (Some(m), Some(c)) = (triple(manifest_version), triple(current)) else { - return false; - }; - match channel { - Channel::Stable => m > c, - Channel::Canary => { - if (m.0, m.1) != (c.0, c.1) { - return (m.0, m.1) > (c.0, c.1); - } - let manifest_run = manifest_ci_run.or_else(|| canary_run(manifest_version)); - match (manifest_run, canary_run(current)) { - (Some(mr), Some(cr)) => mr > cr, - _ => false, - } - } - } +/// The host ladder over an explicit probe — the seam the tests use. +#[cfg(test)] +fn classify(p: &pf_update_check::detect::Probe) -> (InstallKind, Channel) { + classify_shared(p, Product::Host) } /// The per-kind "how to update" command the console shows while (or instead of) an apply /// path existing (design §5). One line, copy-pastable, no placeholders. -pub(crate) fn channel_hint(kind: InstallKind) -> &'static str { - match kind { - InstallKind::WindowsInstaller => { - "winget upgrade unom.PunktfunkHost (or re-run the newer installer)" - } - InstallKind::Sysext => "sudo punktfunk-sysext update", - InstallKind::RpmOstree => { - "sudo /usr/share/punktfunk/update-punktfunk.sh (staged; reboot to finish)" - } - InstallKind::Apt => "sudo apt update && sudo apt install --only-upgrade punktfunk-host", - InstallKind::Dnf => "sudo dnf upgrade punktfunk", - InstallKind::Pacman => "sudo pacman -Syu", - InstallKind::SteamosSource => "bash ~/punktfunk/scripts/steamdeck/update.sh --pull", - InstallKind::Nix => "nix flake update punktfunk (then rebuild your system)", - InstallKind::Source => "git pull && cargo build --release -p punktfunk-host", - } +pub(crate) fn channel_hint(kind: InstallKind) -> String { + pf_update_check::detect::update_command(kind, Product::Host) } #[cfg(test)] mod tests { use super::*; - fn probe() -> Probe { - Probe { - windows: false, - exe: PathBuf::from("/usr/bin/punktfunk-host"), - home: Some(PathBuf::from("/home/deck")), + /// The shared crate owns the ladder's own tests; this one guards the host's BINDING of + /// it — that we ask as `Product::Host`, which is what makes a Deck build report the + /// source-rebuild leg instead of the client's plain `source`. + #[test] + fn host_binding_reports_the_deck_source_leg() { + let p = pf_update_check::detect::Probe { + exe: "/home/deck/punktfunk/target-steamos/release/punktfunk-host".into(), + home: Some("/home/deck".into()), ..Default::default() - } - } - - #[test] - fn ladder_sysext_beats_marker() { - let mut p = probe(); - p.sysext = true; - p.marker = Some("dnf canary".into()); - p.sysext_conf = Some("CHANNEL=canary\n".into()); - assert_eq!(classify(&p), (InstallKind::Sysext, Channel::Canary)); - p.sysext_conf = None; - assert_eq!(classify(&p), (InstallKind::Sysext, Channel::Stable)); - } - - #[test] - fn ladder_nix_store_path() { - let mut p = probe(); - p.exe = PathBuf::from("/nix/store/abc123-punktfunk-host-0.22.2/bin/punktfunk-host"); - assert_eq!(classify(&p).0, InstallKind::Nix); - } - - #[test] - fn ladder_cargo_target_is_source_even_under_home() { - let mut p = probe(); - p.exe = PathBuf::from("/home/deck/punktfunk/target/release/punktfunk-host"); - assert_eq!(classify(&p).0, InstallKind::Source); - } - - #[test] - fn ladder_deck_build_is_steamos_source() { - let mut p = probe(); - p.exe = PathBuf::from("/home/deck/punktfunk/target-steamos/release/punktfunk-host"); + }; assert_eq!(classify(&p).0, InstallKind::SteamosSource); } #[test] - fn ladder_markers() { - for (marker, ostree, kind, channel) in [ - ("apt stable", false, InstallKind::Apt, Channel::Stable), - ("apt canary", false, InstallKind::Apt, Channel::Canary), - ("dnf stable", false, InstallKind::Dnf, Channel::Stable), - ("dnf stable", true, InstallKind::RpmOstree, Channel::Stable), - ("pacman canary", false, InstallKind::Pacman, Channel::Canary), - ] { - let mut p = probe(); - p.marker = Some(marker.into()); - p.ostree_booted = ostree; - assert_eq!(classify(&p), (kind, channel), "marker `{marker}`"); - } - } - - #[test] - fn ladder_unknown_marker_falls_through_to_source() { - let mut p = probe(); - p.marker = Some("snap stable".into()); - assert_eq!(classify(&p).0, InstallKind::Source); - } - - #[test] - fn triples() { - assert_eq!(triple("0.23.0"), Some((0, 23, 0))); - assert_eq!(triple("0.23.0~ci10250.gab12cd34"), Some((0, 23, 0))); - assert_eq!(triple("0.23.10250"), Some((0, 23, 10250))); - assert_eq!(triple("garbage"), None); - assert_eq!(triple("1.2"), None); - } - - #[test] - fn canary_runs() { - assert_eq!(canary_run("0.23.0~ci10250.gab12cd34"), Some(10250)); - assert_eq!(canary_run("0.23.0-0.ci777.g12345678"), Some(777)); - assert_eq!(canary_run("0.23.10250"), Some(10250)); // run-as-patch (Windows/decky) - assert_eq!(canary_run("0.23.0"), None); // stable string - assert_eq!(canary_run("0.23.0-1"), None); - } - - #[test] - fn newer_stable() { - assert!(is_newer("0.23.0", None, "0.22.2", Channel::Stable)); - assert!(!is_newer("0.22.2", None, "0.22.2", Channel::Stable)); - assert!(!is_newer("0.22.1", None, "0.22.2", Channel::Stable)); // downgrade never flags - assert!(!is_newer("not-a-version", None, "0.22.2", Channel::Stable)); - } - - #[test] - fn newer_canary_compares_runs_not_patch() { - // deb canary current vs Windows-style manifest version, same run ⇒ NOT newer, - // even though a naive triple compare says 10250 > 0. - assert!(!is_newer( - "0.23.10250", - Some(10250), - "0.23.0~ci10250.gab12cd34", - Channel::Canary - )); - assert!(is_newer( - "0.23.10251", - Some(10251), - "0.23.0~ci10250.gab12cd34", - Channel::Canary - )); - // Minor bump wins outright. - assert!(is_newer( - "0.24.100", - Some(100), - "0.23.0~ci10250.g12", - Channel::Canary - )); - // No run extractable on either side ⇒ conservative false. - assert!(!is_newer("0.23.10250", None, "0.23.0", Channel::Canary)); - } - - #[test] - fn windows_channel_heuristic() { - assert_eq!(windows_channel_of("0.22.2"), Channel::Stable); - assert_eq!(windows_channel_of("0.23.10118"), Channel::Canary); + fn hints_name_the_host_package() { + assert!(channel_hint(InstallKind::Apt).contains("punktfunk-host")); + assert!(channel_hint(InstallKind::Sysext).contains("punktfunk-sysext update")); } } diff --git a/scripts/ci/publish-update-manifest.sh b/scripts/ci/publish-update-manifest.sh index 7c644d6f..80d58d31 100644 --- a/scripts/ci/publish-update-manifest.sh +++ b/scripts/ci/publish-update-manifest.sh @@ -7,7 +7,7 @@ # # The signature is a raw 64-byte Ed25519 over the EXACT manifest bytes, base64 in the .sig — # the same format the plugin index uses and `store::index::verify_signature` checks. The -# public half is pinned in the host binary (UPDATE_KEYS in crates/punktfunk-host/src/update.rs); +# public half is pinned in the shared checker (OFFICIAL_UPDATE_KEYS in crates/pf-update-check); # before signing, this script cross-checks the signing key against that constant and refuses # on mismatch — the most likely deploy mistake is signing with a key no host trusts (the # sysext publisher's fingerprint-crosscheck drill). @@ -68,14 +68,20 @@ fi # Cross-check: the key we are about to sign with must be one the host binary pins. PUB="ed25519:$(openssl pkey -in "$KEY" -pubout -outform DER | tail -c 32 | base64)" -KEYS_FILE="crates/punktfunk-host/src/update.rs" -if [ -f "$KEYS_FILE" ]; then - if ! grep -qF "\"$PUB\"" "$KEYS_FILE"; then - echo "ERROR: signing key $PUB is not pinned in $KEYS_FILE (UPDATE_KEYS) — wrong key?" >&2 - exit 1 - fi -else - echo "WARN: $KEYS_FILE not in this checkout — skipping the pinned-key cross-check" >&2 +# The pin list moved to the shared checker when the Linux client started verifying the same +# manifest (crates/pf-update-check/src/lib.rs, OFFICIAL_UPDATE_KEYS) — one list, so the host +# and the client can never disagree about who may announce a release. +KEYS_FILE="crates/pf-update-check/src/lib.rs" +# A MISSING file is fatal, not a warning. This check is the guard against signing with a key +# no build trusts; if its path ever goes stale the old `else` branch would have skipped it +# silently and published an unverifiable manifest — the exact failure it exists to prevent. +if [ ! -f "$KEYS_FILE" ]; then + echo "ERROR: $KEYS_FILE not in this checkout — refusing to sign without the pinned-key cross-check" >&2 + exit 1 +fi +if ! grep -qF "\"$PUB\"" "$KEYS_FILE"; then + echo "ERROR: signing key $PUB is not pinned in $KEYS_FILE (OFFICIAL_UPDATE_KEYS) — wrong key?" >&2 + exit 1 fi # ---- build the manifest ---------------------------------------------------------------------