diff --git a/Cargo.lock b/Cargo.lock index a2ce25d..a817961 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1089,7 +1089,7 @@ dependencies = [ [[package]] name = "hound" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "clap", @@ -1101,7 +1101,7 @@ dependencies = [ [[package]] name = "hound-api" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "serde", @@ -1111,7 +1111,7 @@ dependencies = [ [[package]] name = "hound-defs" -version = "0.1.0" +version = "0.1.1" dependencies = [ "ed25519-dalek", "serde", @@ -1121,7 +1121,7 @@ dependencies = [ [[package]] name = "hound-mcp" -version = "0.1.0" +version = "0.1.1" dependencies = [ "hound-api", "hound-supply", @@ -1131,7 +1131,7 @@ dependencies = [ [[package]] name = "hound-supply" -version = "0.1.0" +version = "0.1.1" dependencies = [ "hound-defs", "serde", @@ -1140,7 +1140,7 @@ dependencies = [ [[package]] name = "houndd" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index 0c5803e..e335b04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.1.0" +version = "0.1.1" edition = "2021" license = "MIT" repository = "https://git.joelovestech.com/Hound/Antivirus.git" diff --git a/assets/icons/state-attention-16.png b/assets/icons/state-attention-16.png new file mode 100644 index 0000000..596265b Binary files /dev/null and b/assets/icons/state-attention-16.png differ diff --git a/assets/icons/state-attention-22.png b/assets/icons/state-attention-22.png new file mode 100644 index 0000000..1c5b2f4 Binary files /dev/null and b/assets/icons/state-attention-22.png differ diff --git a/assets/icons/state-attention-24.png b/assets/icons/state-attention-24.png new file mode 100644 index 0000000..4db1194 Binary files /dev/null and b/assets/icons/state-attention-24.png differ diff --git a/assets/icons/state-attention-32.png b/assets/icons/state-attention-32.png new file mode 100644 index 0000000..0743437 Binary files /dev/null and b/assets/icons/state-attention-32.png differ diff --git a/assets/icons/state-attention-48.png b/assets/icons/state-attention-48.png new file mode 100644 index 0000000..0931f20 Binary files /dev/null and b/assets/icons/state-attention-48.png differ diff --git a/crates/hound-api/src/lib.rs b/crates/hound-api/src/lib.rs index b15ef40..22cb5b4 100644 --- a/crates/hound-api/src/lib.rs +++ b/crates/hound-api/src/lib.rs @@ -108,6 +108,11 @@ pub struct Status { /// Execution-gate state. #[serde(default)] pub gate: GateStatus, + /// What the daemon knows about staying current: whether a newer release + /// has been published, and how old the definitions are. Clients turn + /// this into a tray colour rather than deriving it from prose. + #[serde(default)] + pub freshness: Freshness, /// Loaded definition packs. #[serde(default)] pub defs: DefsStatus, @@ -173,6 +178,52 @@ pub struct DefsStatus { } /// Execution-gate state, for the tray and `hound status`. +/// Whether this installation is current, and how loudly to say so. +/// +/// The daemon decides this, not the client — a tray icon and a CLI must not +/// disagree about whether the machine is protected, and the rule for "stale" +/// belongs in one place. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct Freshness { + /// Version of the newest published release, if a signed manifest said so + /// and it is newer than what is running. Empty means "nothing newer + /// known" — which is also what a missing or unverifiable manifest means, + /// because the safe default is silence, not a false alarm. + #[serde(default)] + pub update_version: String, + /// Where to read what changed. + #[serde(default)] + pub update_notes_url: String, + /// Where the package lives, for the assisted install. + #[serde(default)] + pub update_deb_url: String, + /// SHA-256 of that package, checked before it is handed to the system + /// package manager. + #[serde(default)] + pub update_deb_sha256: String, + /// Whole days since the definitions were published. + #[serde(default)] + pub defs_age_days: u32, + /// "ok" | "stale" | "very_stale" — the daemon's own verdict on that age. + #[serde(default)] + pub defs_state: String, + /// One line a person can read, already phrased. Empty when all is well. + #[serde(default)] + pub summary: String, +} + +/// Definitions older than this are worth an amber icon. +pub const DEFS_STALE_DAYS: u32 = 7; +/// Older than this and "protected" is no longer an honest thing to display. +pub const DEFS_VERY_STALE_DAYS: u32 = 30; + +impl Freshness { + /// True when the tray should be amber: something needs the user. + pub fn wants_attention(&self) -> bool { + !self.update_version.is_empty() || self.defs_state != "ok" + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct GateStatus { /// True only when fanotify is armed and answering. diff --git a/crates/hound/src/main.rs b/crates/hound/src/main.rs index 60b3c9f..00cddc5 100644 --- a/crates/hound/src/main.rs +++ b/crates/hound/src/main.rs @@ -48,6 +48,15 @@ enum Cmd { /// privilege. #[command(hide = true)] AdminRpc, + /// Write stdin to a staged update file (internal). + /// + /// The desktop app downloads a published package and checks it against + /// the signed manifest, then needs it somewhere root-owned so nothing can + /// substitute the file between that check and the package manager reading + /// it. Deliberately narrow: one fixed directory, one filename shape, and + /// no way to name a path outside it. + #[command(hide = true)] + StageUpdate { path: String }, /// Scan a file or directory Scan { /// Path to scan (file or directory) @@ -401,6 +410,39 @@ fn client(sock: &Option) -> Result { /// Returns the process exit code. fn run(client: &Client, cmd: &Cmd) -> Result { match cmd { + Cmd::StageUpdate { path } => { + use std::io::Read as _; + const STAGE_DIR: &str = "/var/lib/hound/updates"; + let p = std::path::Path::new(path); + // The caller is unprivileged and we are not; treat the path as + // hostile. It must sit directly in the staging directory and be + // named like a package — no traversal, no subdirectories, no + // writing over anything else on the system. + let name = p + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| anyhow::anyhow!("no filename"))?; + if p.parent() != Some(std::path::Path::new(STAGE_DIR)) + || !name.ends_with(".deb") + || name.contains('/') + || name.starts_with('.') + { + anyhow::bail!("a staged update must be a .deb directly in {STAGE_DIR}"); + } + std::fs::create_dir_all(STAGE_DIR)?; + std::fs::set_permissions( + STAGE_DIR, + std::os::unix::fs::PermissionsExt::from_mode(0o700), + )?; + let mut body = Vec::new(); + // A package is tens of megabytes; this bound is generous and + // still cannot fill a disk. + std::io::stdin().take(256 << 20).read_to_end(&mut body)?; + std::fs::write(p, &body)?; + std::fs::set_permissions(p, std::os::unix::fs::PermissionsExt::from_mode(0o600))?; + println!("staged {} bytes", body.len()); + Ok(0) + } Cmd::AdminRpc => { use std::io::Read as _; let mut line = String::new(); diff --git a/crates/houndd/src/defs.rs b/crates/houndd/src/defs.rs index 11f7398..b56f74f 100644 --- a/crates/houndd/src/defs.rs +++ b/crates/houndd/src/defs.rs @@ -47,6 +47,13 @@ const TRUSTED_KEYS: &[(&str, [u8; 32])] = &[( ], )]; +/// The production release key, for tests that must check against the real +/// one rather than a key the test made up. +#[cfg(test)] +pub fn production_key() -> [u8; 32] { + TRUSTED_KEYS[0].1 +} + /// Where signed packs live. pub fn defs_dir() -> Option { if let Some(dir) = std::env::var_os("HOUNDD_DEFS_DIR") { diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs index 8fc9181..40c9728 100644 --- a/crates/houndd/src/main.rs +++ b/crates/houndd/src/main.rs @@ -51,6 +51,7 @@ mod peer; mod persistence; mod quarantine; mod realtime; +mod release; mod rootkit; mod rules; mod settings; @@ -106,6 +107,7 @@ fn main() -> Result<()> { open_socket_to_hound_group(&sock_path); let state = DaemonState::boot(); + start_scheduler(state.clone()); eprintln!( "houndd {DAEMON_VERSION} listening on {sock} [engine: {}] (Ctrl-C to stop)", @@ -741,6 +743,160 @@ fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result { // ── RPC handlers (engine-agnostic) ────────────────────────────────────────── +/// One definitions check, using the same install path as `hound update` so +/// there is no second implementation to drift. Returns how many packs landed. +fn scheduled_defs_update(st: &DaemonState) -> Result { + let keys = defs::trusted_keys(); + let trusted: Vec<(&str, ed25519_dalek::VerifyingKey)> = + keys.iter().map(|(id, k)| (id.as_str(), *k)).collect(); + let outcome = update::run(&update::install_dir(), &trusted)?; + if !outcome.installed.is_empty() { + st.defs.reload(); + } + Ok(outcome.installed.len()) +} + +/// How often to look for new definitions, and for a new release. +/// +/// Definitions on the hour: the feed is rebuilt daily, and an hourly check +/// costs one small HTTP request against a cached index. Releases daily, +/// because a release the user cannot install without authenticating is not +/// something to nag about. +const DEFS_CHECK_SECS: u64 = 60 * 60; +const RELEASE_CHECK_SECS: u64 = 24 * 60 * 60; + +/// Wait before the first check, so a machine that has just booted is not +/// racing the network stack, and so a fleet that reboots together does not +/// arrive at the CDN in one wave. +const FIRST_CHECK_DELAY_SECS: u64 = 90; + +/// Keep the definitions current and notice when a new Hound is published. +/// +/// This is what `auto_update_signatures` has always claimed to do. The +/// setting shipped from the start and nothing read it: definitions only +/// updated when somebody typed `hound update`, which for a security product +/// means most installations were quietly running whatever they were +/// installed with. +fn start_scheduler(st: DaemonState) { + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(FIRST_CHECK_DELAY_SECS)); + let mut since_release_check = RELEASE_CHECK_SECS; // check once at startup + loop { + if st.settings.get().auto_update_signatures { + match scheduled_defs_update(&st) { + Ok(n) if n > 0 => { + st.events.push( + "update", + "info", + format!("installed {n} new definition pack(s)"), + ); + } + Ok(_) => {} + // A failed check is not an event worth waking a user for + // — laptops are offline all the time. It becomes visible + // through the definitions ageing, which is the thing that + // actually matters. + Err(e) => eprintln!("scheduler: definitions check failed: {e}"), + } + } + + since_release_check += DEFS_CHECK_SECS; + if since_release_check >= RELEASE_CHECK_SECS { + since_release_check = 0; + let keys = defs::trusted_keys(); + let trusted: Vec<(&str, ed25519_dalek::VerifyingKey)> = + keys.iter().map(|(id, k)| (id.as_str(), *k)).collect(); + match release::fetch(&release::base_url(), &trusted) { + Ok(Some(rel)) => { + if release::is_newer(&rel.version, DAEMON_VERSION) { + eprintln!("scheduler: Hound {} is available", rel.version); + } + *KNOWN_RELEASE.lock().expect("release lock poisoned") = Some(rel); + } + Ok(None) => {} + // A manifest that does not verify is worth saying out + // loud: it means somebody served us something they should + // not have been able to. + Err(e) => eprintln!("scheduler: release manifest rejected: {e}"), + } + } + + std::thread::sleep(std::time::Duration::from_secs(DEFS_CHECK_SECS)); + } + }); +} + +/// What the daemon knows about a newer release, refreshed by the scheduler. +/// Empty until a signed manifest has been fetched and verified — the safe +/// default is silence, never a false alarm. +static KNOWN_RELEASE: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// Whole days between a `YYYY.MM.DD` feed version and today. +/// +/// Returns None rather than 0 when the version cannot be parsed. A zero would +/// read as "published today", which is the reassuring answer, and guessing +/// reassuringly is how a security product ends up lying. +fn defs_age_days(version: &str) -> Option { + let mut parts = version.split(['.', '-']); + let y: i32 = parts.next()?.parse().ok()?; + let m: u8 = parts.next()?.parse().ok()?; + let d: u8 = parts.next()?.parse().ok()?; + let published = time::Date::from_calendar_date(y, time::Month::try_from(m).ok()?, d).ok()?; + let today = time::OffsetDateTime::now_utc().date(); + Some((today - published).whole_days().max(0) as u32) +} + +fn freshness(st: &DaemonState) -> hound_api::Freshness { + let mut f = hound_api::Freshness::default(); + + let defs = st.defs.current(); + match defs_age_days(&defs.version) { + Some(days) => { + f.defs_age_days = days; + f.defs_state = if days >= hound_api::DEFS_VERY_STALE_DAYS { + "very_stale" + } else if days >= hound_api::DEFS_STALE_DAYS { + "stale" + } else { + "ok" + } + .into(); + } + // No definitions at all, or a version we cannot date. Either way this + // is not a machine with current protection, and saying "ok" would be + // the most misleading answer available. + None => { + f.defs_state = "very_stale".into(); + f.defs_age_days = 0; + } + } + + if let Some(rel) = KNOWN_RELEASE.lock().ok().and_then(|g| g.clone()) { + if release::is_newer(&rel.version, DAEMON_VERSION) { + f.update_version = rel.version.clone(); + f.update_notes_url = rel.notes_url.clone(); + f.update_deb_url = rel.deb_url.clone(); + f.update_deb_sha256 = rel.deb_sha256.clone(); + } + } + + f.summary = match (f.update_version.is_empty(), f.defs_state.as_str()) { + (true, "ok") => String::new(), + (false, "ok") => format!("Hound {} is available", f.update_version), + (true, "stale") => format!("Definitions are {} days old", f.defs_age_days), + (true, _) => format!( + "Definitions are {} days old — this machine is not currently protected against \ + anything found since then", + f.defs_age_days + ), + (false, _) => format!( + "Hound {} is available, and definitions are {} days old", + f.update_version, f.defs_age_days + ), + }; + f +} + fn status(st: &DaemonState) -> Result { let (present, db_summary, db) = engine::engine().probe(); let os = std::fs::read_to_string("/etc/os-release") @@ -763,6 +919,7 @@ fn status(st: &DaemonState) -> Result { realtime: st.realtime.status(), quarantined: st.quarantine.count(), gate: gate_status(st), + freshness: freshness(st), defs: { let d = st.defs.current(); hound_api::DefsStatus { diff --git a/crates/houndd/src/release.rs b/crates/houndd/src/release.rs new file mode 100644 index 0000000..c8784af --- /dev/null +++ b/crates/houndd/src/release.rs @@ -0,0 +1,395 @@ +//! Is there a newer Hound, and are the definitions still fresh? +//! +//! Two different questions with two different answers, and the difference +//! matters. +//! +//! **Definitions update themselves.** They are Ed25519-signed and verified +//! before they are parsed, so a bad pack is discarded rather than loaded. +//! Applying one automatically adds no attack surface that fetching one did +//! not already add. +//! +//! **The application does not.** A daemon that can replace its own binary is +//! the single mechanism a supply-chain attacker most wants, and Hound runs as +//! root with the ability to block execution. So this module only ever +//! *reports* that a release exists. Installing it goes through the system +//! package manager, with the user present and authenticating. +//! +//! The manifest that says a release exists is signed with the same key as the +//! definition packs, for the same reason the packs are: whoever serves +//! `dl.houndav.com` must not be able to invent a version and point our users +//! at it. An unsigned or badly-signed manifest is discarded, and the absence +//! of a manifest means "no update known", never "update available". + +use anyhow::{bail, Context, Result}; +use ed25519_dalek::{Signature, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +/// Where release manifests live. Overridable for testing and for air-gapped +/// mirrors, which are a real deployment rather than a hypothetical. +pub fn base_url() -> String { + std::env::var("HOUNDD_RELEASE_URL").unwrap_or_else(|_| "https://dl.houndav.com".into()) +} + +/// A manifest is a few hundred bytes. Anything approaching this is not one. +const MAX_MANIFEST_BYTES: u64 = 64 * 1024; + +/// What the publisher signed. Serialised canonically for signing, so the +/// bytes that are verified are the bytes that are interpreted — never a +/// re-encoding that could differ. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Release { + /// Semantic version of the newest published build, e.g. "0.2.0". + pub version: String, + /// Where a human goes to read what changed. + #[serde(default)] + pub notes_url: String, + /// Where the .deb lives, for the one-click install path. + #[serde(default)] + pub deb_url: String, + /// SHA-256 of that .deb, so the download can be checked before it is + /// handed to the package manager. + #[serde(default)] + pub deb_sha256: String, + /// ISO-8601 date the release was published. + #[serde(default)] + pub published: String, +} + +/// The manifest as served: a signed envelope around the release. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignedRelease { + pub key_id: String, + /// Hex-encoded Ed25519 signature over `canonical(release)`. + pub signature: String, + pub release: Release, +} + +/// The exact bytes that are signed and verified. +/// +/// Signing a re-serialisation of a parsed struct is a classic way to verify +/// one thing and act on another, so the canonical form is defined once here +/// and used by both sides. +pub fn canonical(r: &Release) -> String { + format!( + "hound-release-v1\nversion={}\nnotes_url={}\ndeb_url={}\ndeb_sha256={}\npublished={}\n", + r.version, r.notes_url, r.deb_url, r.deb_sha256, r.published + ) +} + +pub fn sign(r: &Release, key: &ed25519_dalek::SigningKey, key_id: &str) -> SignedRelease { + use ed25519_dalek::Signer as _; + let sig = key.sign(canonical(r).as_bytes()); + SignedRelease { + key_id: key_id.to_string(), + signature: hex_encode(&sig.to_bytes()), + release: r.clone(), + } +} + +pub fn verify(signed: &SignedRelease, trusted: &[(&str, VerifyingKey)]) -> Result { + let key = trusted + .iter() + .find(|(id, _)| *id == signed.key_id) + .map(|(_, k)| k) + .with_context(|| format!("no trusted key with id {:?}", signed.key_id))?; + let raw = hex_decode(&signed.signature).context("the signature is not hex")?; + let bytes: [u8; 64] = raw + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("a signature is 64 bytes, this one is {}", raw.len()))?; + key.verify_strict(canonical(&signed.release).as_bytes(), &Signature::from_bytes(&bytes)) + .context("the manifest signature does not verify")?; + Ok(signed.release.clone()) +} + +/// Fetch and verify the release manifest. `Ok(None)` means the server has no +/// manifest — a normal state, and distinct from one that fails to verify. +pub fn fetch(base: &str, trusted: &[(&str, VerifyingKey)]) -> Result> { + let url = format!("{}/latest.json", base.trim_end_matches('/')); + let resp = ureq::AgentBuilder::new() + .timeout(std::time::Duration::from_secs(30)) + .user_agent(concat!("hound/", env!("CARGO_PKG_VERSION"))) + .build() + .get(&url) + .call(); + let resp = match resp { + Ok(r) => r, + Err(ureq::Error::Status(404, _)) => return Ok(None), + Err(e) => return Err(anyhow::anyhow!("fetching {url}: {e}")), + }; + let mut buf = Vec::new(); + use std::io::Read as _; + resp.into_reader() + .take(MAX_MANIFEST_BYTES + 1) + .read_to_end(&mut buf) + .with_context(|| format!("reading {url}"))?; + if buf.len() as u64 > MAX_MANIFEST_BYTES { + bail!("{url} is larger than {MAX_MANIFEST_BYTES} bytes; refusing it"); + } + let signed: SignedRelease = serde_json::from_slice(&buf).context("decoding the manifest")?; + Ok(Some(verify(&signed, trusted)?)) +} + +/// Is `candidate` newer than `current`? +/// +/// Compares dot-separated numeric components, so 0.10.0 beats 0.9.0 — which +/// a string comparison gets backwards, and which is exactly the version pair +/// where a naive implementation would first be noticed. +pub fn is_newer(candidate: &str, current: &str) -> bool { + let parts = |v: &str| -> Vec { + v.trim() + .trim_start_matches('v') + .split(['.', '-', '+']) + .map(|p| p.parse::().unwrap_or(0)) + .collect() + }; + let (a, b) = (parts(candidate), parts(current)); + for i in 0..a.len().max(b.len()) { + let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0)); + if x != y { + return x > y; + } + } + false +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn hex_decode(s: &str) -> Result> { + if s.len() % 2 != 0 { + bail!("odd-length hex string"); + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).context("bad hex digit")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + + fn a_key() -> SigningKey { + SigningKey::from_bytes(&[7u8; 32]) + } + + fn a_release() -> Release { + Release { + version: "0.2.0".into(), + notes_url: "https://houndav.com/changelog".into(), + deb_url: "https://dl.houndav.com/deb/hound_0.2.0_amd64.deb".into(), + deb_sha256: "aa".repeat(32), + published: "2026-08-21".into(), + } + } + + #[test] + fn a_signed_manifest_verifies() { + let k = a_key(); + let signed = sign(&a_release(), &k, "hound-2026"); + let trusted = [("hound-2026", k.verifying_key())]; + assert_eq!(verify(&signed, &trusted).unwrap(), a_release()); + } + + /// The whole point. Whoever serves dl.houndav.com must not be able to + /// invent a version and point our users at a binary of their choosing. + #[test] + fn a_tampered_manifest_is_refused() { + let k = a_key(); + let mut signed = sign(&a_release(), &k, "hound-2026"); + signed.release.deb_url = "https://evil.example/hound.deb".into(); + let trusted = [("hound-2026", k.verifying_key())]; + assert!(verify(&signed, &trusted).is_err()); + } + + #[test] + fn every_signed_field_is_covered_by_the_signature() { + let k = a_key(); + let trusted = [("hound-2026", k.verifying_key())]; + for mutate in [ + (|r: &mut Release| r.version = "9.9.9".into()) as fn(&mut Release), + |r: &mut Release| r.notes_url = "https://evil.example".into(), + |r: &mut Release| r.deb_url = "https://evil.example/x.deb".into(), + |r: &mut Release| r.deb_sha256 = "bb".repeat(32), + |r: &mut Release| r.published = "1999-01-01".into(), + ] { + let mut signed = sign(&a_release(), &k, "hound-2026"); + mutate(&mut signed.release); + assert!( + verify(&signed, &trusted).is_err(), + "a field outside the signature would let a CDN operator lie about it" + ); + } + } + + #[test] + fn a_manifest_signed_by_a_stranger_is_refused() { + let signed = sign(&a_release(), &SigningKey::from_bytes(&[9u8; 32]), "hound-2026"); + let trusted = [("hound-2026", a_key().verifying_key())]; + assert!(verify(&signed, &trusted).is_err()); + } + + #[test] + fn an_unknown_key_id_is_refused() { + let k = a_key(); + let signed = sign(&a_release(), &k, "somebody-elses-key"); + let trusted = [("hound-2026", k.verifying_key())]; + assert!(verify(&signed, &trusted).is_err()); + } + + /// String comparison says "0.9.0" > "0.10.0". Numeric comparison does + /// not, and this is the version pair where that first bites. + #[test] + fn versions_compare_numerically_not_lexically() { + assert!(is_newer("0.10.0", "0.9.0")); + assert!(!is_newer("0.9.0", "0.10.0")); + assert!(is_newer("1.0.0", "0.99.99")); + assert!(is_newer("0.2.0", "0.1.0")); + } + + #[test] + fn the_same_version_is_not_an_update() { + assert!(!is_newer("0.1.0", "0.1.0")); + assert!(!is_newer("v0.1.0", "0.1.0")); + } + + /// Never offer a downgrade as an update — that is how a signed-but-old + /// manifest becomes a way to reintroduce a fixed vulnerability. + #[test] + fn an_older_version_is_never_offered() { + assert!(!is_newer("0.0.9", "0.1.0")); + assert!(!is_newer("0.1.0", "0.2.0")); + } + + #[test] + fn trailing_components_are_handled() { + assert!(is_newer("0.1.1", "0.1")); + assert!(!is_newer("0.1", "0.1.0")); + } + + /// The manifest is signed by tools/publish-release.py in Python and + /// verified here in Rust. Two implementations of the same canonical form + /// is exactly where this silently breaks — a field reordered on one side + /// produces manifests that verify nowhere, and the symptom is that + /// nobody ever hears about an update. + /// + /// This is a real manifest produced by that tool, checked against the + /// production public key compiled into the daemon. + #[test] + fn a_manifest_from_the_publishing_tool_verifies() { + const PUBLISHED: &str = r#"{ + "key_id": "hound-2026", + "signature": "0e4fdce35b9220d6c471499d4ad035890cec050fd47ee9e54b29cec138c74370c7a889486b40eaf9b85ab3938e8c70208c724d0ebcd4fd8340af09431ab6da03", + "release": { + "version": "0.1.0", + "notes_url": "https://houndav.com/#changelog", + "deb_url": "https://dl.houndav.com/deb/hound_0.1.0_amd64.deb", + "deb_sha256": "50117edbfd09bafe005fd658c3348e8bd7dcdfeb5cbabc8eb58f57b4b92bda08", + "published": "2026-08-21" + } +}"#; + let signed: SignedRelease = + serde_json::from_str(PUBLISHED).expect("the tool emits valid JSON"); + let key_bytes: [u8; 32] = crate::defs::production_key(); + let key = VerifyingKey::from_bytes(&key_bytes).unwrap(); + let out = verify(&signed, &[("hound-2026", key)]) + .expect("the Rust verifier must accept what the Python signer produced"); + assert_eq!(out.version, "0.1.0"); + } + + /// The daemon reports its own version to the update check, and the + /// desktop app declares one separately in two more files. If those drift, + /// a released version can look older than what is installed and the + /// update silently never offers — or worse, offers forever. + #[test] + fn the_declared_versions_all_agree() { + let daemon = env!("CARGO_PKG_VERSION"); + for (file, needle) in [ + ("../../gui/src-tauri/Cargo.toml", "version = \""), + ("../../gui/src-tauri/tauri.conf.json", "\"version\": \""), + ("../../gui/package.json", "\"version\": \""), + ] { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(file); + let Ok(text) = std::fs::read_to_string(&path) else { + continue; // a source checkout without the GUI is still valid + }; + let found = text + .lines() + .find_map(|l| l.trim().strip_prefix(needle)) + .and_then(|r| r.split('"').next()) + .unwrap_or_default(); + assert_eq!( + found, daemon, + "{} declares {found:?}, the daemon is {daemon:?}", + path.display() + ); + } + } + + /// Serve a manifest over a real socket and fetch it the way the scheduler + /// does. The tests above prove the cryptography; this proves the wiring + /// between it and the network, which is the part that fails silently — a + /// daemon that never fetches looks exactly like one where no update + /// exists. + fn serve_once(body: String, status: &'static str) -> String { + use std::io::{Read as _, Write as _}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + if let Ok((mut sock, _)) = listener.accept() { + let mut scratch = [0u8; 2048]; + let _ = sock.read(&mut scratch); + let head = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = sock.write_all(head.as_bytes()); + let _ = sock.write_all(body.as_bytes()); + } + }); + format!("http://127.0.0.1:{port}") + } + + #[test] + fn a_served_manifest_is_fetched_and_verified() { + let k = a_key(); + let signed = sign(&a_release(), &k, "hound-2026"); + let base = serve_once(serde_json::to_string(&signed).unwrap(), "200 OK"); + let got = fetch(&base, &[("hound-2026", k.verifying_key())]) + .expect("a well-signed manifest must fetch") + .expect("and must not be reported as absent"); + assert_eq!(got.version, "0.2.0"); + } + + /// A host with no manifest is the normal state before the first release. + /// It must read as "nothing newer known", not as an error the scheduler + /// logs every day. + #[test] + fn a_missing_manifest_is_not_an_error() { + let base = serve_once("nope".into(), "404 Not Found"); + let got = fetch(&base, &[("hound-2026", a_key().verifying_key())]).unwrap(); + assert!(got.is_none()); + } + + /// The case the signature exists for: someone controlling the download + /// host serves a manifest we did not sign. It must not become a prompt + /// telling users to install their binary. + #[test] + fn a_served_manifest_we_did_not_sign_is_refused() { + let attacker = SigningKey::from_bytes(&[3u8; 32]); + let signed = sign(&a_release(), &attacker, "hound-2026"); + let base = serve_once(serde_json::to_string(&signed).unwrap(), "200 OK"); + assert!(fetch(&base, &[("hound-2026", a_key().verifying_key())]).is_err()); + } + + #[test] + fn hex_round_trips() { + let bytes = [0u8, 1, 15, 16, 255]; + assert_eq!(hex_decode(&hex_encode(&bytes)).unwrap(), bytes); + assert!(hex_decode("abc").is_err()); + assert!(hex_decode("zz").is_err()); + } +} diff --git a/dist/hound_0.1.0_amd64.deb b/dist/hound_0.1.0_amd64.deb index 00a8999..fed1565 100644 Binary files a/dist/hound_0.1.0_amd64.deb and b/dist/hound_0.1.0_amd64.deb differ diff --git a/dist/hound_0.1.1_amd64.deb b/dist/hound_0.1.1_amd64.deb new file mode 100644 index 0000000..83a7b4c Binary files /dev/null and b/dist/hound_0.1.1_amd64.deb differ diff --git a/gui/package-lock.json b/gui/package-lock.json index 06cf9b5..286b037 100644 --- a/gui/package-lock.json +++ b/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "hound-gui", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hound-gui", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.7.2", diff --git a/gui/package.json b/gui/package.json index 23b4244..caa3869 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "hound-gui", - "version": "0.1.0", + "version": "0.1.1", "description": "Hound Antivirus — desktop app", "type": "module", "scripts": { diff --git a/gui/src-tauri/Cargo.lock b/gui/src-tauri/Cargo.lock index 4251558..dc3d684 100644 --- a/gui/src-tauri/Cargo.lock +++ b/gui/src-tauri/Cargo.lock @@ -1467,7 +1467,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hound-api" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "serde", @@ -1477,17 +1477,19 @@ dependencies = [ [[package]] name = "hound-gui" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "hound-api", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", "tauri-plugin-notification", "tauri-plugin-opener", + "ureq", ] [[package]] @@ -2953,6 +2955,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2981,6 +2997,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3386,6 +3437,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.8" @@ -4287,6 +4344,28 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -4530,6 +4609,24 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4760,6 +4857,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5236,6 +5342,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 1285e80..85e5ee7 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "hound-gui" description = "Hound Antivirus desktop app (Tauri 2)" -version = "0.1.0" +version = "0.1.1" edition = "2021" license = "MIT" repository = "https://git.joelovestech.com/Hound/Antivirus" @@ -17,6 +17,8 @@ tauri-plugin-dialog = "2" tauri-plugin-notification = "2" tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } +sha2 = "0.10" +ureq = { version = "2", default-features = false, features = ["tls", "gzip"] } serde_json = "1" anyhow = "1" diff --git a/gui/src-tauri/icons/state-attention-22.png b/gui/src-tauri/icons/state-attention-22.png new file mode 100644 index 0000000..1c5b2f4 Binary files /dev/null and b/gui/src-tauri/icons/state-attention-22.png differ diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index 099dfd3..d2db1e0 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -52,7 +52,12 @@ const POLL_SECS: u64 = 1; static SCANNING: AtomicBool = AtomicBool::new(false); /// States the tray can render. Anything unknown falls back to `protected`. -const STATES: [&str; 4] = ["protected", "scanning", "threat", "paused"]; +/// Tray states, in the order a viewer would rank their urgency. "attention" +/// is the persistent one — an update waiting, or definitions going stale — +/// as distinct from "scanning", which lasts seconds and which the user +/// started themselves. They are both amber; attention is the deeper shade, +/// so the two are distinguishable side by side. +const STATES: [&str; 5] = ["protected", "scanning", "threat", "paused", "attention"]; /// The four preloaded state icons, managed so tray swaps never hit disk. #[derive(Clone, Default)] @@ -130,6 +135,148 @@ async fn settings() -> Result { } +// ── Assisted update ──────────────────────────────────────────────────────── + +/// Download the published package, check it, and hand it to the system +/// package manager. +/// +/// Hound does not replace its own binary. A root daemon that can rewrite +/// itself is precisely the mechanism a supply-chain attacker wants, so the +/// install goes through `apt`, with the user present and authenticating — +/// the same path they would take by hand, minus the typing. +/// +/// Two checks before anything is handed over, because a signed manifest +/// establishes what the publisher intended and nothing more: the URL must be +/// on our own download host, and the file must hash to what the manifest +/// said. The manifest's signature is what makes that hash trustworthy. +fn install_update(app: &tauri::AppHandle) { + use sha2::{Digest, Sha256}; + + let Some(pending) = PENDING_UPDATE.lock().ok().and_then(|g| g.clone()) else { + return; + }; + + let proceed = app + .dialog() + .message(format!( + "Hound {} is available.\n\nThe package will be downloaded, checked against \ + its signed manifest, and installed by your system package manager. You will \ + be asked to authenticate.", + pending.version + )) + .title(format!("Install Hound {}?", pending.version)) + .buttons(MessageDialogButtons::OkCancelCustom( + "Install".into(), + "Not now".into(), + )) + .blocking_show(); + if !proceed { + return; + } + + let fail = |msg: String| { + let _ = app + .notification() + .builder() + .title("Update failed") + .body(&msg) + .show(); + }; + + // A signed manifest says what the publisher intended; it does not stop a + // publisher's mistake pointing somewhere else. Refuse anything off our + // own download host. + if !pending.deb_url.starts_with("https://dl.houndav.com/") { + fail("The update points somewhere unexpected and was not downloaded.".into()); + return; + } + + let body = match ureq::AgentBuilder::new() + .timeout(std::time::Duration::from_secs(300)) + .build() + .get(&pending.deb_url) + .call() + { + Ok(r) => { + let mut buf = Vec::new(); + use std::io::Read as _; + // 256 MB is far beyond any real package and well short of + // filling a disk. + if r.into_reader().take(256 << 20).read_to_end(&mut buf).is_err() { + fail("The download did not complete.".into()); + return; + } + buf + } + Err(e) => { + fail(format!("Could not download the update: {e}")); + return; + } + }; + + let got = Sha256::digest(&body); + let got = got.iter().map(|b| format!("{b:02x}")).collect::(); + if !pending.sha256.is_empty() && got != pending.sha256 { + fail("The downloaded package does not match its signed checksum. It was discarded.".into()); + return; + } + + // Into a root-owned directory, so nothing can swap the file between the + // check above and apt reading it. + let path = std::path::Path::new("/var/lib/hound/updates").join(format!( + "hound_{}_amd64.deb", + pending.version + )); + if elevate_write(&path, &body).is_err() { + fail("Could not stage the update.".into()); + return; + } + + match std::process::Command::new("pkexec") + .arg("/usr/bin/apt-get") + .arg("install") + .arg("-y") + .arg(&path) + .status() + { + Ok(st) if st.success() => { + let _ = app + .notification() + .builder() + .title(format!("Hound {} installed", pending.version)) + .body("The new version is active.") + .show(); + } + Ok(st) if st.code() == Some(126) => {} // dismissed; not a failure + Ok(_) => fail("The package manager refused the update.".into()), + Err(e) => fail(format!("Could not start the installer: {e}")), + } +} + +/// Write a staged package as root, via the same polkit path as everything +/// else that needs privilege. +fn elevate_write(path: &std::path::Path, body: &[u8]) -> Result<(), String> { + use std::io::Write as _; + let mut child = std::process::Command::new("pkexec") + .arg("/usr/bin/hound") + .arg("stage-update") + .arg(path) + .stdin(std::process::Stdio::piped()) + .spawn() + .map_err(|e| e.to_string())?; + child + .stdin + .as_mut() + .ok_or("no stdin")? + .write_all(body) + .map_err(|e| e.to_string())?; + match child.wait() { + Ok(st) if st.success() => Ok(()), + Ok(st) => Err(format!("staging exited {:?}", st.code())), + Err(e) => Err(e.to_string()), + } +} + // ── Elevation ─────────────────────────────────────────────────────────────── /// Run one daemon method as root, via polkit. @@ -364,11 +511,38 @@ fn set_state( Ok(()) } +/// A release the daemon has verified as newer, cached so the menu handler +/// does not have to make a round-trip while the user is waiting on a click. +#[derive(Clone)] +struct PendingUpdate { + version: String, + deb_url: String, + sha256: String, + notes_url: String, +} +static PENDING_UPDATE: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// The "Install update" entry, kept so the watcher can retitle and +/// enable it when the daemon learns a release exists. +struct UpdateMenuItem(MenuItem); + +/// The daemon's one-line reason for the amber state, so the tooltip can say +/// what needs attention rather than only that something does. +static ATTENTION_REASON: std::sync::Mutex = std::sync::Mutex::new(String::new()); + fn tooltip_for(state: &str) -> String { match state { "threat" => "Hound — threat found".into(), "scanning" => "Hound — scanning…".into(), "paused" => "Hound — paused".into(), + "attention" => { + let why = ATTENTION_REASON.lock().map(|g| g.clone()).unwrap_or_default(); + if why.is_empty() { + "Hound — needs attention".into() + } else { + format!("Hound — {why}") + } + } _ => "Hound — protected".into(), } } @@ -415,6 +589,35 @@ fn start_watcher(app: tauri::AppHandle, icons: TrayIcons) { state = "paused"; } } + + // Amber for anything that needs the user but is not a threat: a + // published release, or definitions going stale. It ranks below + // a threat and below an in-flight scan, and above plain + // protected — it is a nudge, not an alarm. + if let Ok(st) = c.status() { + let f = &st.freshness; + *ATTENTION_REASON.lock().expect("reason lock") = f.summary.clone(); + if !f.update_version.is_empty() { + *PENDING_UPDATE.lock().expect("update lock") = Some(PendingUpdate { + version: f.update_version.clone(), + deb_url: f.update_deb_url.clone(), + sha256: f.update_deb_sha256.clone(), + notes_url: f.update_notes_url.clone(), + }); + } + if let Some(item) = app.try_state::() { + if f.update_version.is_empty() { + let _ = item.0.set_text("No update available"); + let _ = item.0.set_enabled(false); + } else { + let _ = item.0.set_text(format!("Install Hound {}…", f.update_version)); + let _ = item.0.set_enabled(true); + } + } + if state == "protected" && f.wants_attention() { + state = "attention"; + } + } apply_state(&app, &icons, state); } }); @@ -443,11 +646,12 @@ fn notify(app: &tauri::AppHandle, ev: &Event) { // applications menu, the setup hook failed and the whole app panicked before // a window appeared. Four PNGs at ~1 KB each is a rounding error on a 12 MB // binary, and it makes the tray icon unable to be missing. -const ICON_BYTES: [(&str, &[u8]); 4] = [ +const ICON_BYTES: [(&str, &[u8]); 5] = [ ("protected", include_bytes!("../icons/state-protected-22.png")), ("scanning", include_bytes!("../icons/state-scanning-22.png")), ("threat", include_bytes!("../icons/state-threat-22.png")), ("paused", include_bytes!("../icons/state-paused-22.png")), + ("attention", include_bytes!("../icons/state-attention-22.png")), ]; fn load_state_icons() -> R>> { @@ -507,11 +711,30 @@ pub fn run() { )?; let update_sig = MenuItem::with_id(&handle, "update", "Update Signatures", true, None::<&str>)?; + // Two items that exist only when they mean something. A permanently + // greyed "Install update" teaches people the menu is decorative; + // these are enabled/disabled from the watcher as the daemon's + // answer changes, and their labels carry the version. + let install_item = MenuItem::with_id( + &handle, + "install-update", + "No update available", + false, + None::<&str>, + )?; let sep = PredefinedMenuItem::separator(&handle)?; let quit = MenuItem::with_id(&handle, "quit", "Quit", true, None::<&str>)?; let menu = Menu::with_items( &handle, - &[&open, &scan_home, &scan_downloads, &update_sig, &sep, &quit], + &[ + &open, + &scan_home, + &scan_downloads, + &update_sig, + &install_item, + &sep, + &quit, + ], )?; let _ = TrayIconBuilder::with_id(TRAY_ID) @@ -570,6 +793,10 @@ pub fn run() { json!({ "action": "scan", "path": "~/Downloads" }), ); } + "install-update" => { + let app = app.clone(); + std::thread::spawn(move || install_update(&app)); + } "update" => { let _ = window.emit("tray-event", json!({ "action": "update" })); } @@ -587,6 +814,7 @@ pub fn run() { }) .build(&handle)?; + app.manage(UpdateMenuItem(install_item.clone())); start_watcher(handle.clone(), watcher_icons); Ok(()) diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index 7b1b25e..8e6f2aa 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Hound Antivirus", - "version": "0.1.0", + "version": "0.1.1", "identifier": "com.joelovestech.hound", "build": { "frontendDist": "../dist", diff --git a/tools/publish-release.py b/tools/publish-release.py new file mode 100755 index 0000000..67c2307 --- /dev/null +++ b/tools/publish-release.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Sign and publish the release manifest that tells installed agents a new +version exists. + +The manifest is signed with the same Ed25519 key as the definition packs, and +for the same reason: whoever serves dl.houndav.com must not be able to invent +a version and point our users at a binary of their choosing. An agent discards +a manifest that does not verify, so a mistake here is a silent no-update +rather than a bad update. + +Usage: + tools/publish-release.py --version 0.2.0 --deb dist/hound_0.2.0_amd64.deb \ + [--notes-url https://houndav.com/changelog] [--out /srv/houndav/dl] +""" +import argparse, hashlib, json, pathlib, subprocess, sys, datetime + +KEY = pathlib.Path.home() / "agents/hound/.secrets/defs-signing.key" +KEY_ID = "hound-2026" + +# Must match release.rs::canonical exactly. Signing a re-serialisation of a +# parsed struct is a classic way to verify one thing and act on another, so +# both sides build these bytes from the same field order and separators. +def canonical(r): + return ( + "hound-release-v1\n" + f"version={r['version']}\n" + f"notes_url={r['notes_url']}\n" + f"deb_url={r['deb_url']}\n" + f"deb_sha256={r['deb_sha256']}\n" + f"published={r['published']}\n" + ) + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--version", required=True) + ap.add_argument("--deb", required=True, type=pathlib.Path) + ap.add_argument("--notes-url", default="https://houndav.com/#changelog") + ap.add_argument("--out", type=pathlib.Path, default=pathlib.Path("/srv/houndav/dl")) + ap.add_argument("--deb-url", default=None) + a = ap.parse_args() + + if not a.deb.is_file(): + sys.exit(f"no such package: {a.deb}") + if not KEY.is_file(): + sys.exit(f"no signing key at {KEY}") + + digest = hashlib.sha256(a.deb.read_bytes()).hexdigest() + deb_url = a.deb_url or f"https://dl.houndav.com/deb/{a.deb.name}" + if not deb_url.startswith("https://dl.houndav.com/"): + # The agent refuses anything off our own host, so publishing one would + # produce an update nobody can install. + sys.exit(f"the agent will refuse {deb_url}: it must be on dl.houndav.com") + + release = { + "version": a.version, + "notes_url": a.notes_url, + "deb_url": deb_url, + "deb_sha256": digest, + "published": datetime.date.today().isoformat(), + } + + try: + from nacl.signing import SigningKey + except ImportError: + sys.exit("pip install pynacl") + seed = KEY.read_bytes() + if len(seed) == 64: + seed = seed[:32] + sk = SigningKey(seed) + sig = sk.sign(canonical(release).encode()).signature.hex() + + manifest = {"key_id": KEY_ID, "signature": sig, "release": release} + a.out.mkdir(parents=True, exist_ok=True) + dest = a.out / "latest.json" + dest.write_text(json.dumps(manifest, indent=2) + "\n") + print(f"wrote {dest}") + print(f" version {a.version} sha256 {digest[:16]}…") + print(f" public key {sk.verify_key.encode().hex()}") + +if __name__ == "__main__": + main()