Agent only ever runs a binary whose minisign signature verifies against
the EMBEDDED public key. NATS host.cmd func 'update' {url}: download
binary + .minisig from the CDN -> verify against embedded pubkey ->
atomic swap (.old rollback) -> relaunch. URL allowlist (https + cdn.
corrosionmgmt.com only, rejects userinfo-bypass), 100MiB cap. Closes the
supply-chain hole: even a malicious CDN upload can't run unsigned.
CI: build-host-agent.yml signs every artifact with MINISIGN_SECRET_KEY
(Gitea secret) and publishes .minisig alongside; the step FAILS the
build if the secret is absent (refuses to ship unsigned). Bumped to
alpha.6.
6 deterministic tests (accept valid / reject tampered+garbage+empty sig,
URL allowlist incl userinfo-bypass, atomic swap+rollback). Fixtures
signed with the real release key so tests need no key at runtime. Full
suite 50/50 green; musl + native build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
64 lines
2.5 KiB
Rust
64 lines
2.5 KiB
Rust
//! Signed self-update tests — the security-critical part is signature
|
|
//! verification: a valid signature is accepted, anything tampered is rejected.
|
|
//! Fixtures (tests/fixtures/sample.bin + .minisig) were signed with the real
|
|
//! release private key, so these run with no key present (as in CI).
|
|
|
|
use corrosion_host_agent::update;
|
|
|
|
const SAMPLE: &[u8] = include_bytes!("fixtures/sample.bin");
|
|
const SAMPLE_SIG: &str = include_str!("fixtures/sample.bin.minisig");
|
|
|
|
#[test]
|
|
fn accepts_a_validly_signed_binary() {
|
|
update::verify_signature(SAMPLE, SAMPLE_SIG).expect("valid signature must verify");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_a_tampered_binary() {
|
|
let mut tampered = SAMPLE.to_vec();
|
|
tampered[0] ^= 0xFF; // flip a byte
|
|
let err = update::verify_signature(&tampered, SAMPLE_SIG)
|
|
.expect_err("tampered binary must be rejected");
|
|
assert!(err.to_string().contains("verification failed"), "got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_a_garbage_signature() {
|
|
assert!(update::verify_signature(SAMPLE, "not a real minisig blob").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_empty_binary_against_real_sig() {
|
|
assert!(update::verify_signature(b"", SAMPLE_SIG).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn url_allowlist_enforced() {
|
|
// Allowed.
|
|
update::assert_url_allowed("https://cdn.corrosionmgmt.com/host-agent/alpha/corrosion-host-agent-linux-amd64")
|
|
.expect("the real CDN host must be allowed");
|
|
// http rejected.
|
|
assert!(update::assert_url_allowed("http://cdn.corrosionmgmt.com/x").is_err());
|
|
// wrong host rejected.
|
|
assert!(update::assert_url_allowed("https://evil.example.com/x").is_err());
|
|
// credential-in-URL (userinfo bypass) rejected.
|
|
assert!(update::assert_url_allowed("https://cdn.corrosionmgmt.com:[email protected]/x").is_err());
|
|
// host as userinfo trick rejected (real host is evil.com).
|
|
assert!(update::assert_url_allowed("https://[email protected]/x").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn swap_binary_replaces_and_backs_up() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let current = dir.path().join("corrosion-host-agent");
|
|
std::fs::write(¤t, b"OLD BINARY").unwrap();
|
|
|
|
update::swap_binary(¤t, b"NEW BINARY").expect("swap should succeed");
|
|
|
|
assert_eq!(std::fs::read(¤t).unwrap(), b"NEW BINARY", "current is the new binary");
|
|
let backup = dir.path().join("corrosion-host-agent.old");
|
|
assert_eq!(std::fs::read(&backup).unwrap(), b"OLD BINARY", ".old holds the previous binary");
|
|
// the .new scratch file is consumed by the rename
|
|
assert!(!dir.path().join("corrosion-host-agent.new").exists());
|
|
}
|