Skip to content

UP-01: validate signed update versions, channels and CIDs - #132

Merged
andersalm merged 8 commits into
developfrom
fix/0.7.1-update-validation
Oct 1, 2026
Merged

andersalm merged 8 commits into
developfrom
fix/0.7.1-update-validation

Conversation

@andersalm

@andersalm andersalm commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

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.json bytes. 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 ea670e27 merges cleanly. Candidate CI run 36735599578 has been rerun for the cancelled jobs.

Installed Mac proof at 47658723 passed 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.

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.
@andersalm andersalm mentioned this pull request Sep 30, 2026
5 tasks done
@andersalm
andersalm marked this pull request as ready for review September 30, 2026 16:19
@andersalm
andersalm requested a review from irzhywau September 30, 2026 16:19
@andersalm andersalm mentioned this pull request Sep 30, 2026
7 of 12 tasks
@andersalm andersalm added this to the 0.7.2 milestone Sep 30, 2026

@irzhywau irzhywau left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, 1095 copies the signed head's payload.version into sources.json installed_version without 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 generic elastos 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.rs and operator_control.rs used a call counter to map each fetch to a fixed filename and ignored the requested CID. --head-cid / --rollback-to therefore 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_available used to be latest != installed, so a replayed older head looked like an update. It is now SemVer precedence and refuses older releases. Remote operator apply hard-codes force=false, no head override, and Carrier-only transport.
  • Channel binding at discovery and again before install. The announced release_cid must equal the signed latest_release_cid. A discovery verification failure is now terminal instead of falling back to another transport.
  • Byte fidelity: the installed components.json is 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: add pub fn validate_update_components_manifest(bytes: &[u8], platform: &str) -> anyhow::Result<()>. Parse ComponentsManifest. For every external entry with a platform entry for platform:

    • require no dev strategy (local-copy, source-build);
    • require a non-empty checksum matching ^(sha256:[0-9a-f]{64}|sha512:[0-9a-f]{128})$.

    Mirror checksum_error() in scripts/components-release-integrity-check.py, and add a comment linking the two.

  • update.rs: call it right after Components 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.json is unchanged;
    • a local-copy entry → same result.

2. Recovery path for an invalid installed version (F1), in this PR or a follow-up

  • update.rs: before compare_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 when force is 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.
  • install.sh (after :895): validate RELEASE_VERSION with the same regex as scripts/check-versioning.sh. install.sh runs standalone, so inline the regex with a keep in sync comment. 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 valid installed_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 to verify_release_binding as the binding that makes it safe, plus a note that release_sha256 must stay mandatory.

4. Head freshness (F2), follow-up issue under #106, design needed

  • elastos.release.head/v2 adds a signed expires_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.

@andersalm
andersalm merged commit 3cb2765 into develop Oct 1, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants