Repository navigation
UP-01: validate signed update versions, channels and CIDs - #132
Conversation
Resolve legacy and current publisher announcements through the same verified head path for CLI and operator checks/apply. Keep explicit CID requests exact and preserve discovery failures across transport fallback. Validation: 22 focused update tests; git diff --check; Home entropy; workspace and chain-provider formatting.
irzhywau
left a comment
There was a problem hiding this comment.
Security review — #132 UP-01: validate signed update versions, channels and CIDs
@andersalm — security review of head c87b0eb4 against develop. Review only; no changes were made to the branch.
Verdict: approve. This PR closes real holes in the update trust chain (see Security improvements). No finding blocks the merge. F1–F3 are Low defence-in-depth items and F4 is a comment request. A fix plan is at the end: what to land here, and what to track as follow-ups.
Severity scale: Critical exploitable now with direct impact · High realistic path to code execution or integrity loss · Medium needs an extra condition, or blocks a security-relevant channel · Low hardening · Info no action.
F1 — Low · A non-SemVer installed version permanently blocks updates and has no recovery path
Where:
elastos/crates/elastos-server/src/update.rs:125—compare_release_versions()hard-fails on a non-SemVer installed version;- used by CLI apply (
run_upgrade_from_head), operator apply, and operator check (operator_control.rs::gather_local_update_check); scripts/install.sh:895, 1095copies the signed head'spayload.versionintosources.jsoninstalled_versionwithout checking it.
Context. The publish path is already gated: publish-release.sh:1494 runs scripts/check-versioning.sh under set -euo pipefail, which accepts only X.Y.Z[-alpha|beta|rc.N][+meta]. So a bad version cannot be signed through the normal publish flow.
Residual threat. A non-SemVer installed_version can still arrive from:
- a head signed outside
publish-release.sh(the genericelastos sign-payload); - an older installer;
- a manual edit.
In that state every update fails with Invalid installed release version, including remote operator apply. The updater is the security-patch channel, and the only recovery is hand-editing sources.json. Fail-closed is correct; what's missing is a recovery path.
F2 — Low (pre-existing) · Signed heads have no freshness, so an explicit gateway can silently hold back updates
Where: update.rs::try_gateway_head_discovery → run_upgrade_from_head. The head schema has no expires_at / issued_at.
Threat. On the explicit --gateway transport, the gateway can keep serving an older but validly signed head. Older than installed is now refused loudly, which is good. Equal to installed reports a silent Installed release is up to date. — a freeze attack that hides security releases. Carrier discovery comes from the ticket-authenticated publisher node and is far less exposed.
F3 — Low · The manifest is installed without checking it can launch its providers
Where: update.rs:950-952 (components verified, then binary replaced) and step 11 (components.json rename). Update-time stamping (write_installed_manifest) was removed, which is correct for byte fidelity. Launch verification fails closed on a missing checksum (binaries.rs:159-167).
Threat. If a published components-<platform>.json ever contains a native provider without a checksum, or a dev strategy (local-copy / source-build), the update still replaces the binary. Then the provider refuses to launch. Integrity holds, but the Home is left degraded after the point of no return. components-release-integrity-check.py (run by publish-release.sh:2202, 2350) already rejects this at publish time, so this is defence in depth.
F4 — Info · The gateway path skips the release CID check; this is safe, but say so in the code
Where: run_upgrade_from_head — if working_gateway.is_none() { verify_release_metadata_cid(release_cid, &release_bytes)?; }. This is sound because verify_release_binding requires the signed head's release_sha256 to match the release bytes. Without a comment, a future change that makes release_sha256 optional for legacy heads would silently open a substitution path.
Info · Interrupted apply leaves a mixed state (tracked)
Exit 75 between the binary and manifest writes leaves a new binary with an old manifest and source version, as the Mac proof shows. Integrity holds. This belongs to #108 and #109.
Security improvements in this PR
- The CID is now actually used to fetch. Before, the fetch closures in
release_cmd.rsandoperator_control.rsused a call counter to map each fetch to a fixed filename and ignored the requested CID.--head-cid/--rollback-totherefore silently fetched whatever the source currently served. Fetch is now by CID, and head/release bytes are checked against it (verify_release_metadata_cid, fail-closed). - Downgrade protection on the operator path.
update_availableused to belatest != installed, so a replayed older head looked like an update. It is now SemVer precedence and refuses older releases. Remote operator apply hard-codesforce=false, no head override, and Carrier-only transport. - Channel binding at discovery and again before install. The announced
release_cidmust equal the signedlatest_release_cid. A discovery verification failure is now terminal instead of falling back to another transport. - Byte fidelity: the installed
components.jsonis exactly the publisher's verified bytes, with a regression test.
Fix plan
None of these block the merge. Items 1–3 are small enough to land in this PR or in an immediate follow-up on the same branch. Item 4 needs a design decision and gets its own issue.
1. Pre-install manifest admission (F3), in this PR or a follow-up
-
setup.rs: addpub fn validate_update_components_manifest(bytes: &[u8], platform: &str) -> anyhow::Result<()>. ParseComponentsManifest. For everyexternalentry with a platform entry forplatform:- require no dev
strategy(local-copy,source-build); - require a non-empty
checksummatching^(sha256:[0-9a-f]{64}|sha512:[0-9a-f]{128})$.
Mirror
checksum_error()inscripts/components-release-integrity-check.py, and add a comment linking the two. - require no dev
-
update.rs: call it right afterComponents verified (SHA-256 ✓)(:950) and before// 10. Atomic replace binary. -
Tests:
- a manifest with one provider missing its checksum → update errors, the installed binary is byte-identical to before, and
sources.jsonis unchanged; - a
local-copyentry → same result.
- a manifest with one provider missing its checksum → update errors, the installed binary is byte-identical to before, and
2. Recovery path for an invalid installed version (F1), in this PR or a follow-up
update.rs: beforecompare_release_versions, detect!installed.is_empty() && semver::Version::parse(installed).is_err():- CLI apply: print the bad value and
Repair: elastos update --force (installs the signed release and rewrites installed_version). Proceed only whenforceis set, treating it as the legacy empty state. - Operator apply/check: stay fail-closed, with the message
installed version '<v>' is not SemVer; run 'elastos update --force' locally on this Home.
- CLI apply: print the bad value and
install.sh(after:895): validateRELEASE_VERSIONwith the same regex asscripts/check-versioning.sh.install.shruns standalone, so inline the regex with akeep in synccomment. Add a source-gate check that the two regexes are identical.- Tests:
- invalid installed + CLI without force → refusal with the hint;
- invalid installed +
force→ applies and writes a validinstalled_version; - operator → refuses with the hint.
3. Comment on the gateway CID skip (F4), in this PR
- One line above the
working_gateway.is_none()check, pointing toverify_release_bindingas the binding that makes it safe, plus a note thatrelease_sha256must stay mandatory.
4. Head freshness (F2), follow-up issue under #106, design needed
elastos.release.head/v2adds a signedexpires_at. The client reports an expired head as freshness unknown, never up to date, and the publisher re-signs the head before expiry. The re-signing cadence needs publisher-key operations.- Interim, cheap: when the explicit gateway transport returns "up to date", also print
head freshness is not verified on explicit transport.
Acceptance: CI green; the new tests above pass. No new Mac proof is needed for 1–3, since they only touch refusal paths and don't change the happy-path install.
Not run here: the Rust test suite. The 14 new focused tests were reviewed by reading only.
Fixes #107. Includes reviewed #79 through a normal merge of
feat/0.7.1-models.CLI and operator updates compare parsed SemVer, verify the signed source channel, and fetch the requested CID. Head and release CIDs remain distinct. Legacy publisher announcements use their verified signed head to bind the release CID. Verification errors stop the update.
Support-asset refresh preserves the verified
components.jsonbytes. Source-home setup keeps local checksum stamping; downloaded-asset receipts stay separate from the publisher manifest.Validation: 22 update tests, 66 setup tests, seven observer tests and the four basic gates pass. The byte/SHA regression fails before the fix. Independent source review passed the correction. Current models base
ea670e27merges cleanly. Candidate CI run 36735599578 has been rerun for the cancelled jobs.Installed Mac proof at
47658723passed CLI/operator downgrade and wrong-channel refusals, explicit older-CID selection, and CLI Apply with exact manifest bytes after changed-asset refresh. The two version-stamped binaries share this source tree.Operator Apply exited 75 after binary replacement and before the manifest write. An ordinary CLI retry completed the update; both Homes then started explicitly with matching artifact hashes and preserved fixture data. Complete-set staging and interruption recovery belong to #108; automatic restart and rollback belong to #109. This result stays visible and establishes neither of those journeys. Exact proof and remaining acceptance are recorded in #107.