Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/colony-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <asset>
Expand Down
16 changes: 15 additions & 1 deletion src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<colony_apps_dir>/<repo_name>/<filename>`,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -564,7 +575,10 @@ pub fn apply_launcher_update(new_binary: &std::path::Path) -> Result<PathBuf> {
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)]
{
Expand Down
15 changes: 15 additions & 0 deletions src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,11 @@ pub struct ColonyManifest {
/// at the repo root, then falls back to the tinted category hexagon.
#[serde(default)]
pub icon: Option<String>,
/// When true, every release asset MUST ship a valid `<asset>.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).
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions src/signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
///
Expand All @@ -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)"))
}

Expand Down
9 changes: 8 additions & 1 deletion src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -525,14 +530,15 @@ 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(),
filename: resolved_file.clone(),
binary_name: binary,
expected_sha256,
record_asset: file_pattern.is_some(),
require_signature,
},
Some(progress_tx),
)
Expand Down Expand Up @@ -1522,6 +1528,7 @@ mod tests {
platforms: vec!["linux".into()],
release_files,
icon: None,
signed: false,
},
}
}
Expand Down