diff --git a/docs/colony-spec.md b/docs/colony-spec.md index 8192bc8..8d82e27 100644 --- a/docs/colony-spec.md +++ b/docs/colony-spec.md @@ -214,7 +214,16 @@ signs the launcher itself - see `docs/release-signing.md` and the embedded public key in `src/signing.rs`). An invalid signature aborts the install. An asset without a `.sig` sibling installs as before (legacy unsigned app) - -signing is adopted per-app, no flag day. Sign with: +signing is adopted per-app, no flag day - **unless** the manifest declares: + +```json +{ "signed": true } +``` + +With `"signed": true`, a missing signature ABORTS the install: this closes the +remaining hole where a compromised repository could simply omit signatures. +Declare it once every release of the app ships `.sig` assets (all Project- +Colony apps have signed releases as of 2026-07-20). Sign with: ```sh COLONY_SIGNING_KEY=/path/to/colony-release.pem ./scripts/sign-release.sh diff --git a/src/download.rs b/src/download.rs index 84ff913..7dd16f7 100644 --- a/src/download.rs +++ b/src/download.rs @@ -319,6 +319,10 @@ pub struct AssetInstall { /// persisted next to the binary so `installed_app_path` can find the /// install again. pub record_asset: bool, + /// True when the manifest declares `"signed": true`: a missing `.sig` + /// then ABORTS the install instead of being treated as a legacy unsigned + /// app - closing the "compromised repo simply omits signatures" hole. + pub require_signature: bool, } /// Download a release asset to `//`, @@ -336,6 +340,7 @@ pub async fn download_release_asset( binary_name, expected_sha256, record_asset, + require_signature, } = install; // The manifest-supplied filename becomes a local path — guard it against // traversal (`../`, absolute paths) before joining, mirroring the archive @@ -370,6 +375,12 @@ pub async fn download_release_asset( anyhow::bail!("Could not check for a release signature of {filename}: {e}"); } }; + if require_signature && signature.is_none() { + let _ = std::fs::remove_file(&temp_path); + anyhow::bail!( + "The manifest requires signed releases, but no {filename}.sig was published - refusing to install" + ); + } // Integrity check, archive extraction and the atomic promotion are // CPU/IO-bound — run them on a blocking thread. Any failure removes the @@ -564,7 +575,10 @@ pub fn apply_launcher_update(new_binary: &std::path::Path) -> Result { if staged_next.exists() { let _ = std::fs::remove_file(&staged_next); } - std::fs::copy(new_binary, &staged_next) + // Write the byte buffer that was just VERIFIED - copying the file again + // would re-read from disk and install bytes the signature check never saw + // (a swap between read and copy would slip through). + std::fs::write(&staged_next, &staged_bytes) .map_err(|e| anyhow::anyhow!("Failed to stage new binary: {e}"))?; #[cfg(unix)] { diff --git a/src/github.rs b/src/github.rs index 57635ac..d7a9593 100644 --- a/src/github.rs +++ b/src/github.rs @@ -250,6 +250,11 @@ pub struct ColonyManifest { /// at the repo root, then falls back to the tinted category hexagon. #[serde(default)] pub icon: Option, + /// When true, every release asset MUST ship a valid `.sig` + /// (ed25519, Project-Colony org key): a missing signature aborts the + /// install instead of falling back to the legacy unsigned path. + #[serde(default)] + pub signed: bool, } /// Metadata for a Colony-compatible repository (has colony.json). @@ -1187,6 +1192,16 @@ mod tests { ); } + #[test] + fn manifest_signed_flag_parses_and_defaults_off() { + let json = r#"{ "name": "App", "category": "Utility", "signed": true }"#; + let m: ColonyManifest = serde_json::from_str(json).unwrap(); + assert!(m.signed); + let json = r#"{ "name": "App", "category": "Utility" }"#; + let m: ColonyManifest = serde_json::from_str(json).unwrap(); + assert!(!m.signed, "signed must default to false (legacy manifests)"); + } + #[test] fn find_asset_by_pattern_glob_with_exclusion_resolves_electron_builder_layout() { // SphereCord's real release layout: electron-builder publishes both diff --git a/src/signing.rs b/src/signing.rs index d7cff71..3c3277e 100644 --- a/src/signing.rs +++ b/src/signing.rs @@ -13,7 +13,7 @@ use anyhow::Result; use base64::Engine; -use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use ed25519_dalek::{Signature, VerifyingKey}; /// Colony release signing public key (ed25519, raw 32 bytes). /// @@ -38,7 +38,11 @@ fn verify_with_key(pubkey: &[u8; 32], data: &[u8], signature_bytes: &[u8]) -> Re let sig = parse_signature(signature_bytes)?; let vk = VerifyingKey::from_bytes(pubkey) .map_err(|e| anyhow::anyhow!("invalid release public key: {e}"))?; - vk.verify(data, &sig) + // verify_strict: rejects malleable/non-canonical signatures and small- + // order key components - the recommended verifier for update/security + // contexts (plain verify() accepts signatures strict verification would + // refuse). + vk.verify_strict(data, &sig) .map_err(|_| anyhow::anyhow!("signature verification failed (untrusted or corrupt update)")) } diff --git a/src/update.rs b/src/update.rs index b39dd58..6276c1c 100644 --- a/src/update.rs +++ b/src/update.rs @@ -468,7 +468,12 @@ impl App { let file_pattern = entry.file_pattern.clone(); let binary = entry.binary.clone(); let expected_sha256 = entry.sha256.clone(); + let require_signature = repo.manifest.signed; let repo_name = repo.name.clone(); + // API calls (release resolution) use the token for + // rate limits; the asset download itself is a public + // endpoint and gets NO token - no reason to present + // credentials where none are needed. let token = self.github_token(); let display_name = file .as_deref() @@ -525,7 +530,7 @@ impl App { // mid-install detached the blocking task and // left an installed binary with no metadata. let path = github::download_release_asset( - token, + None, crate::download::AssetInstall { repo_name: repo_name.clone(), tag: resolved_tag.clone(), @@ -533,6 +538,7 @@ impl App { binary_name: binary, expected_sha256, record_asset: file_pattern.is_some(), + require_signature, }, Some(progress_tx), ) @@ -1522,6 +1528,7 @@ mod tests { platforms: vec!["linux".into()], release_files, icon: None, + signed: false, }, } }