From 6b3e805ac2fe7bf69fb33c03c71638d4a3b7c99e Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Thu, 11 Jun 2026 20:00:36 -0400 Subject: [PATCH] feat(host-agent): Phase 3a signed self-update (minisign) + CI signing gate 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 --- .gitea/workflows/build-host-agent.yml | 26 +- corrosion-host-agent/Cargo.lock | 472 +++++++++++++++++- corrosion-host-agent/Cargo.toml | 4 +- corrosion-host-agent/PROTOCOL.md | 1 + corrosion-host-agent/README.md | 3 +- corrosion-host-agent/src/hostcmd.rs | 50 +- corrosion-host-agent/src/lib.rs | 1 + corrosion-host-agent/src/update.rs | 154 ++++++ .../tests/fixtures/sample.bin | 2 + .../tests/fixtures/sample.bin.minisig | 4 + corrosion-host-agent/tests/update.rs | 63 +++ 11 files changed, 751 insertions(+), 29 deletions(-) create mode 100644 corrosion-host-agent/src/update.rs create mode 100644 corrosion-host-agent/tests/fixtures/sample.bin create mode 100644 corrosion-host-agent/tests/fixtures/sample.bin.minisig create mode 100644 corrosion-host-agent/tests/update.rs diff --git a/.gitea/workflows/build-host-agent.yml b/.gitea/workflows/build-host-agent.yml index a8ae1e0..be506a0 100644 --- a/.gitea/workflows/build-host-agent.yml +++ b/.gitea/workflows/build-host-agent.yml @@ -67,6 +67,24 @@ jobs: sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt cat checksums.txt + - name: Sign artifacts (minisign) + env: + MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }} + run: | + if [ -z "$MINISIGN_SECRET_KEY" ]; then + echo "::error::MINISIGN_SECRET_KEY secret is not set — refusing to publish unsigned agent artifacts." + exit 1 + fi + apt-get install -y -qq minisign + printf '%s\n' "$MINISIGN_SECRET_KEY" > /tmp/sign.key + cd corrosion-host-agent/bin + # Passwordless key (-W generated); feed empty stdin so it never blocks. + for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do + minisign -S -s /tmp/sign.key -m "$f" -x "$f.minisig" < /dev/null + done + rm -f /tmp/sign.key + echo "signed: $(ls *.minisig)" + - name: Create Release env: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} @@ -82,7 +100,9 @@ jobs: "${API_URL}/repos/${REPO}/releases") RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') - for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do + for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-linux-amd64.minisig \ + corrosion-host-agent-windows-amd64.exe corrosion-host-agent-windows-amd64.exe.minisig \ + checksums.txt checksums.txt.minisig; do curl -s -X POST \ -H "Authorization: token ${RELEASE_TOKEN}" \ -H "Content-Type: application/octet-stream" \ @@ -95,7 +115,9 @@ jobs: CDN_URL="https://cdn.corrosionmgmt.com" VERSION="${{ steps.version.outputs.VERSION }}" - for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do + for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-linux-amd64.minisig \ + corrosion-host-agent-windows-amd64.exe corrosion-host-agent-windows-amd64.exe.minisig \ + checksums.txt checksums.txt.minisig; do curl -s -X POST \ -F "file=@corrosion-host-agent/bin/$f" \ "${CDN_URL}/host-agent/alpha/$f" diff --git a/corrosion-host-agent/Cargo.lock b/corrosion-host-agent/Cargo.lock index 20f2df6..ae2c593 100644 --- a/corrosion-host-agent/Cargo.lock +++ b/corrosion-host-agent/Cargo.lock @@ -90,7 +90,7 @@ dependencies = [ "nuid", "once_cell", "portable-atomic", - "rand", + "rand 0.8.6", "regex", "ring", "rustls-native-certs", @@ -100,7 +100,7 @@ dependencies = [ "serde_json", "serde_nanos", "serde_repr", - "thiserror", + "thiserror 1.0.69", "time", "tokio", "tokio-rustls", @@ -110,6 +110,12 @@ dependencies = [ "url", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" @@ -180,6 +186,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.45" @@ -264,7 +276,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "corrosion-host-agent" -version = "2.0.0-alpha.5" +version = "2.0.0-alpha.6" dependencies = [ "anyhow", "async-nats", @@ -272,7 +284,9 @@ dependencies = [ "clap", "futures", "libc", - "rand", + "minisign-verify", + "rand 0.8.6", + "reqwest", "serde", "serde_json", "sysinfo", @@ -585,8 +599,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] @@ -597,7 +627,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -633,12 +663,94 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -784,6 +896,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -852,6 +970,12 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -867,6 +991,12 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "mio" version = "1.2.1" @@ -889,7 +1019,7 @@ dependencies = [ "ed25519-dalek", "getrandom 0.2.17", "log", - "rand", + "rand 0.8.6", "signatory", ] @@ -917,7 +1047,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" dependencies = [ - "rand", + "rand 0.8.6", ] [[package]] @@ -1056,6 +1186,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -1065,6 +1250,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1078,8 +1269,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1089,7 +1290,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1101,6 +1312,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rayon" version = "1.12.0" @@ -1159,6 +1379,47 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + [[package]] name = "ring" version = "0.17.14" @@ -1173,6 +1434,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1237,6 +1504,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -1268,6 +1536,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "schannel" version = "0.1.29" @@ -1384,6 +1658,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1438,7 +1724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" dependencies = [ "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "zeroize", ] @@ -1450,7 +1736,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1514,6 +1800,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1558,7 +1853,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -1572,6 +1876,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -1622,6 +1937,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -1727,6 +2057,51 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1788,6 +2163,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tryhard" version = "0.5.2" @@ -1810,9 +2191,9 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.8.6", "sha1", - "thiserror", + "thiserror 1.0.69", "utf-8", ] @@ -1882,6 +2263,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1919,6 +2309,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.123" @@ -1973,6 +2373,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -1985,6 +2398,35 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/corrosion-host-agent/Cargo.toml b/corrosion-host-agent/Cargo.toml index 6d7a990..8ba51b4 100644 --- a/corrosion-host-agent/Cargo.toml +++ b/corrosion-host-agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "corrosion-host-agent" -version = "2.0.0-alpha.5" +version = "2.0.0-alpha.6" edition = "2021" description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers" license = "UNLICENSED" @@ -26,6 +26,8 @@ anyhow = "1" clap = { version = "4.5", features = ["derive"] } rand = "0.8" tokio-tungstenite = "0.24" +minisign-verify = "0.2.5" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/corrosion-host-agent/PROTOCOL.md b/corrosion-host-agent/PROTOCOL.md index 95f91be..24fb979 100644 --- a/corrosion-host-agent/PROTOCOL.md +++ b/corrosion-host-agent/PROTOCOL.md @@ -85,6 +85,7 @@ Request: `{ "func": "" }`. Reply: `{ "status": "success" | "error", ... }` | `ping` | `version`, `commit`, `uptime_seconds` | | `probe` | `report` — fresh ProbeReport (also cached for heartbeat) | | `sysinfo` | `snapshot` — full heartbeat payload, collected on demand | +| `update` | `{ "func": "update", "url": "https://cdn.corrosionmgmt.com/host-agent/.../corrosion-host-agent-" }` → downloads the binary + `.minisig`, verifies the minisign signature against the agent's EMBEDDED public key, atomically swaps (with `.old` rollback), replies `{ status: success, message: "...relaunching" }`, then relaunches the new binary. Rejects anything not signed by the release key and any URL that isn't `https://cdn.corrosionmgmt.com`. | Unknown funcs return `status: "error"` with a message listing supported funcs. diff --git a/corrosion-host-agent/README.md b/corrosion-host-agent/README.md index e8f61d3..a213afe 100644 --- a/corrosion-host-agent/README.md +++ b/corrosion-host-agent/README.md @@ -21,7 +21,8 @@ instance on that host — Rust, Conan Exiles, Soulmask, Dune: Awakening. (integration-tested with real processes + live-NATS contract test) - [ ] Phase 1b: RCON trait (WebRCON rust / TCP conan+soulmask), SteamCMD, jailed file manager - [ ] Phase 2: Dune Docker adapter (compose lifecycle, RabbitMQ bus, Postgres admin) -- [ ] Phase 3: signed self-update (enforced ed25519 — release gate), service install, supervisor split +- [x] Phase 3a: SIGNED self-update — minisign-verified download+swap+relaunch (NATS `update` func); embedded public key; CI signs releases +- [ ] Phase 3b: service install (systemd/SCM), PID adoption ## Build diff --git a/corrosion-host-agent/src/hostcmd.rs b/corrosion-host-agent/src/hostcmd.rs index 18f13af..4db43a5 100644 --- a/corrosion-host-agent/src/hostcmd.rs +++ b/corrosion-host-agent/src/hostcmd.rs @@ -13,11 +13,15 @@ use crate::agent::Agent; use crate::prober; use crate::subjects; use crate::telemetry; +use crate::update; use crate::version; #[derive(Debug, Deserialize)] struct HostCommand { func: String, + /// Signed-update artifact URL (for func = "update"). + #[serde(default)] + url: Option, } pub async fn run(agent: Arc) -> anyhow::Result<()> { @@ -55,20 +59,46 @@ async fn handle(agent: Arc, msg: async_nats::Message) { return; }; - let response = match serde_json::from_slice::(&msg.payload) { - Ok(cmd) => dispatch(&agent, &cmd.func).await, - Err(e) => json!({ "status": "error", "message": format!("invalid command payload: {e}") }), - }; - - let bytes = match serde_json::to_vec(&response) { - Ok(b) => b, + let cmd = match serde_json::from_slice::(&msg.payload) { + Ok(cmd) => cmd, Err(e) => { - tracing::error!("response serialize failed: {e}"); + publish(&agent, &reply, json!({ "status": "error", "message": format!("invalid command payload: {e}") })).await; return; } }; - if let Err(e) = agent.nats.publish(reply, bytes.into()).await { - tracing::warn!("response publish failed: {e}"); + + // Self-update is special: it must reply BEFORE relaunching, because the + // relaunch replaces this process and nothing after it would run. + if cmd.func == "update" { + let Some(url) = cmd.url else { + publish(&agent, &reply, json!({ "status": "error", "message": "update requires a 'url'" })).await; + return; + }; + match update::download_verify_swap(&url).await { + Ok(_) => { + publish(&agent, &reply, json!({ "status": "success", "func": "update", "message": "verified and swapped; relaunching" })).await; + let _ = agent.nats.flush().await; + update::relaunch_and_exit(); + } + Err(e) => { + publish(&agent, &reply, json!({ "status": "error", "func": "update", "message": format!("{e:#}") })).await; + } + } + return; + } + + let response = dispatch(&agent, &cmd.func).await; + publish(&agent, &reply, response).await; +} + +async fn publish(agent: &Arc, reply: &async_nats::Subject, value: serde_json::Value) { + match serde_json::to_vec(&value) { + Ok(bytes) => { + if let Err(e) = agent.nats.publish(reply.clone(), bytes.into()).await { + tracing::warn!("response publish failed: {e}"); + } + } + Err(e) => tracing::error!("response serialize failed: {e}"), } } diff --git a/corrosion-host-agent/src/lib.rs b/corrosion-host-agent/src/lib.rs index ea37b89..8143a6a 100644 --- a/corrosion-host-agent/src/lib.rs +++ b/corrosion-host-agent/src/lib.rs @@ -13,4 +13,5 @@ pub mod rcon; pub mod steamcmd; pub mod subjects; pub mod telemetry; + pub mod update; pub mod version; diff --git a/corrosion-host-agent/src/update.rs b/corrosion-host-agent/src/update.rs new file mode 100644 index 0000000..b10a09c --- /dev/null +++ b/corrosion-host-agent/src/update.rs @@ -0,0 +1,154 @@ +//! Signed self-update. +//! +//! The agent only ever runs a binary whose minisign signature verifies against +//! the EMBEDDED public key below. Even if the CDN (which currently accepts +//! unauthenticated uploads) served a malicious binary, the agent refuses it +//! without a valid signature from the release private key (a CI secret). +//! +//! Flow: download binary + `.minisig` from the CDN → verify signature → +//! atomic swap (current → `.old`, new → current, rollback on failure) → +//! relaunch the new binary. Defence in depth mirrors the Vigilance updater: +//! a real URL parse rejecting credential-in-URL bypasses, an https + host +//! allowlist, and a size cap. + +use anyhow::{bail, Context, Result}; +use minisign_verify::{PublicKey, Signature}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// minisign public key. The matching private key signs releases in CI +/// (Gitea Actions secret MINISIGN_SECRET_KEY). Rotating it means re-signing +/// every published artifact and shipping an agent build with the new key. +const PUBLIC_KEY: &str = "RWQKhJptuiwIkp31cZdz10z/R72UPZkl7/VtnZJ2Vfbe0dQfDlXHZYFC"; + +const ALLOWED_HOST: &str = "cdn.corrosionmgmt.com"; +const MAX_BINARY_BYTES: usize = 100 * 1024 * 1024; // 100 MiB sanity cap +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600); + +/// Verify a binary against the embedded public key + a minisign signature blob. +/// The security core of self-update — tampered or unsigned content is rejected. +pub fn verify_signature(binary: &[u8], signature_blob: &str) -> Result<()> { + let pk = PublicKey::from_base64(PUBLIC_KEY).context("embedded public key is invalid")?; + let sig = Signature::decode(signature_blob).context("malformed minisign signature")?; + pk.verify(binary, &sig, false) + .map_err(|e| anyhow::anyhow!("signature verification failed: {e}"))?; + Ok(()) +} + +/// Reject anything but `https://cdn.corrosionmgmt.com/...` with no embedded +/// credentials (the userinfo-bypass class). +pub fn assert_url_allowed(url: &str) -> Result<()> { + let parsed = reqwest::Url::parse(url).context("invalid update URL")?; + if parsed.scheme() != "https" { + bail!("update URL must be https"); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + bail!("update URL must not contain credentials"); + } + if parsed.host_str() != Some(ALLOWED_HOST) { + bail!("update URL host not allowed: {:?}", parsed.host_str()); + } + Ok(()) +} + +/// Download, verify, and atomically swap in a new agent binary. Does NOT +/// restart — the caller decides when to relaunch (after replying on NATS). +/// Returns the path of the now-current (new) binary. +pub async fn download_verify_swap(url: &str) -> Result { + assert_url_allowed(url)?; + let sig_url = format!("{url}.minisig"); + assert_url_allowed(&sig_url)?; + + let client = reqwest::Client::builder() + .timeout(DOWNLOAD_TIMEOUT) + .build() + .context("building HTTP client")?; + + let binary = client + .get(url) + .send() + .await + .with_context(|| format!("downloading {url}"))? + .error_for_status() + .context("update binary download failed")? + .bytes() + .await + .context("reading update binary")?; + + if binary.len() > MAX_BINARY_BYTES { + bail!("update binary is {} bytes, exceeds the {MAX_BINARY_BYTES} cap", binary.len()); + } + + let signature = client + .get(&sig_url) + .send() + .await + .with_context(|| format!("downloading {sig_url}"))? + .error_for_status() + .context("signature download failed")? + .text() + .await + .context("reading signature")?; + + verify_signature(&binary, &signature).context("refusing unsigned/tampered update")?; + tracing::info!("update signature verified ({} bytes)", binary.len()); + + let current = std::env::current_exe().context("resolving current executable")?; + swap_binary(¤t, &binary)?; + tracing::info!("update swapped in at {}", current.display()); + Ok(current) +} + +/// Atomically replace `current` with `new_bytes`, keeping a `.old` backup and +/// rolling back if the rename fails. +pub fn swap_binary(current: &Path, new_bytes: &[u8]) -> Result<()> { + let dir = current.parent().unwrap_or_else(|| Path::new(".")); + let stem = current.file_name().and_then(|s| s.to_str()).unwrap_or("corrosion-host-agent"); + let new_path = dir.join(format!("{stem}.new")); + let backup = dir.join(format!("{stem}.old")); + + std::fs::write(&new_path, new_bytes) + .with_context(|| format!("writing {}", new_path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&new_path, std::fs::Permissions::from_mode(0o755)) + .context("chmod +x on new binary")?; + } + + let _ = std::fs::remove_file(&backup); + std::fs::rename(current, &backup) + .with_context(|| format!("backing up current binary to {}", backup.display()))?; + + if let Err(e) = std::fs::rename(&new_path, current) { + // Roll back: restore the backup so the agent stays runnable. + let _ = std::fs::rename(&backup, current); + return Err(anyhow::anyhow!(e).context("installing new binary (rolled back)")); + } + Ok(()) +} + +/// Relaunch the (already-swapped) binary with the same args, then exit. No +/// service manager is required — the new process reconnects on its own. There +/// is a sub-second window with no agent; acceptable for an update. +pub fn relaunch_and_exit() -> ! { + let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("corrosion-host-agent")); + let args: Vec = std::env::args().skip(1).collect(); + tracing::info!("relaunching {} after update", exe.display()); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // exec replaces this process image with the new binary — cleanest, + // no gap. Only returns on failure. + let err = std::process::Command::new(&exe).args(&args).exec(); + tracing::error!("exec after update failed: {err}; exiting for service restart"); + std::process::exit(70); + } + #[cfg(not(unix))] + { + let _ = std::process::Command::new(&exe).args(&args).spawn(); + std::process::exit(0); + } +} diff --git a/corrosion-host-agent/tests/fixtures/sample.bin b/corrosion-host-agent/tests/fixtures/sample.bin new file mode 100644 index 0000000..1dbb52f --- /dev/null +++ b/corrosion-host-agent/tests/fixtures/sample.bin @@ -0,0 +1,2 @@ +corrosion-host-agent signed-update test fixture +version 2.0.0-test diff --git a/corrosion-host-agent/tests/fixtures/sample.bin.minisig b/corrosion-host-agent/tests/fixtures/sample.bin.minisig new file mode 100644 index 0000000..7dcec95 --- /dev/null +++ b/corrosion-host-agent/tests/fixtures/sample.bin.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RUQKhJptuiwIkp378Z59BTwosDycAhmlhrdZZVwk1Vdb293OgcsXx0S3W0XezMtOXIXdgvQtW/DpDKlb1gdW4elQXLG5KFUgawI= +trusted comment: timestamp:1781222247 file:sample.bin hashed +QtUiOfJqRKYJZTL6QV93xeLVnODr8HXWvZIR3Q1AG0yqmqesZPyiKpVa9kD34Mwp1fQ76nx1Z7c6CB1v5KHQAw== diff --git a/corrosion-host-agent/tests/update.rs b/corrosion-host-agent/tests/update.rs new file mode 100644 index 0000000..c8011ba --- /dev/null +++ b/corrosion-host-agent/tests/update.rs @@ -0,0 +1,63 @@ +//! 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()); +}