Background
PR #224 shipped `perry/updater` with the Tauri-style signing model: developer signs the SHA-256 digest of each binary with an Ed25519 private key; the runtime verifies the signature against an embedded pinned public key. The signed payload is the raw 32-byte SHA-256 digest of the binary, and only that — the version string is not bound into the signature.
This is documented at `crates/perry-updater/src/core.rs::perry_updater_verify_signature`:
The signed payload is the raw 32-byte SHA-256 digest of the binary —
not the hex string, not the file bytes themselves.
Attack: signed-old-binary replay
Because the version string is in the manifest (unsigned in v1) and not in the signature payload, an on-path attacker can:
- Take a previously-published, validly-signed binary at version `1.2.0` (which had a CVE fixed in `1.3.0`).
- Serve a manifest claiming `{version: "99.0.0", url: <still-pointing-at-real-v1.2.0>, signature: <valid-v1.2.0-signature>}`.
- The running v1.0.0 sees `compareVersions("1.0.0", "99.0.0") == -1` → offers update.
- Downloads the binary, verifies SHA-256 against the manifest claim (matches — it's the real v1.2.0 file), verifies signature against pinned pubkey (matches — that v1.2.0 file was actually signed by the developer).
- Installs and relaunches.
- The new binary's internal version is `1.2.0`. `compareVersions("1.2.0", "99.0.0") == -1` will keep being true on every check until the attacker stops MitM'ing — but in the meantime the user is permanently on the vulnerable version, because the running v1.2.0 sees "99.0.0" as the latest and won't downgrade-detect anything when the legitimate `1.4.0` manifest later arrives at the same URL.
This is a real attack path. It requires:
Both conditions are realistic over a long enough product lifetime.
Mitigations (pick one)
A. Sign `SHA256(binary) || version` instead of just `SHA256(binary)`. The verify step becomes:
```
expected_payload = sha256_digest_bytes (32) || version_string_utf8 (variable)
verify(signed = expected_payload, sig, pubkey)
```
Backwards-incompatible with the v0.1 sign-side tooling, but cleanly fixes the issue. Verify-side change is small.
B. Sign the manifest itself. Add a top-level `manifestSignature: string` field; the signed payload is the canonical-JSON serialization of the manifest minus that field. This is what Tauri does in their `v2` updater. More machinery (canonical JSON, key management for the manifest signer vs the binary signer if they're different) but covers all manifest-borne attacks at once including (1) and (2) from #228.
C. Track per-channel monotonicity in the sentinel. Cheapest patch: persist the highest version ever seen on this machine, refuse to update to anything `<` that. Doesn't help against an attacker who plants a signed `99.0.0` to push you to old code; the attacker controls the "highest seen" too. Insufficient on its own but a useful defense in depth.
D. Reject any candidate version that is `<= currentVersion` AND any version that doesn't match the running binary's internal claim. Forces the running binary to verify that the version it claims to be matches what was offered. This breaks the chain because the planted-as-99.0.0 v1.2.0 binary, on first run after install, sees its own internal version is 1.2.0 (not 99.0.0) and refuses to mark itself healthy. Implementation needs a reliable "what version is this binary really" hook (compile-time `PERRY_VERSION` constant baked into the program, exposed to TS).
I'd lean toward B (manifest signing) since it's the strongest model and matches what Tauri converged on, but A is a reasonable v1.1 increment if the manifest-signing tooling is bigger than we want.
Acceptance criteria
Whichever option:
Out of scope
- Multi-channel rollouts (stable / beta / nightly with separate signing keys).
- Key rotation. Pinned-pubkey-baked-into-binary makes rotation a hard problem worth its own design doc.
Background
PR #224 shipped `perry/updater` with the Tauri-style signing model: developer signs the SHA-256 digest of each binary with an Ed25519 private key; the runtime verifies the signature against an embedded pinned public key. The signed payload is the raw 32-byte SHA-256 digest of the binary, and only that — the version string is not bound into the signature.
This is documented at `crates/perry-updater/src/core.rs::perry_updater_verify_signature`:
Attack: signed-old-binary replay
Because the version string is in the manifest (unsigned in v1) and not in the signature payload, an on-path attacker can:
This is a real attack path. It requires:
Both conditions are realistic over a long enough product lifetime.
Mitigations (pick one)
A. Sign `SHA256(binary) || version` instead of just `SHA256(binary)`. The verify step becomes:
```
expected_payload = sha256_digest_bytes (32) || version_string_utf8 (variable)
verify(signed = expected_payload, sig, pubkey)
```
Backwards-incompatible with the v0.1 sign-side tooling, but cleanly fixes the issue. Verify-side change is small.
B. Sign the manifest itself. Add a top-level `manifestSignature: string` field; the signed payload is the canonical-JSON serialization of the manifest minus that field. This is what Tauri does in their `v2` updater. More machinery (canonical JSON, key management for the manifest signer vs the binary signer if they're different) but covers all manifest-borne attacks at once including (1) and (2) from #228.
C. Track per-channel monotonicity in the sentinel. Cheapest patch: persist the highest version ever seen on this machine, refuse to update to anything `<` that. Doesn't help against an attacker who plants a signed `99.0.0` to push you to old code; the attacker controls the "highest seen" too. Insufficient on its own but a useful defense in depth.
D. Reject any candidate version that is `<= currentVersion` AND any version that doesn't match the running binary's internal claim. Forces the running binary to verify that the version it claims to be matches what was offered. This breaks the chain because the planted-as-99.0.0 v1.2.0 binary, on first run after install, sees its own internal version is 1.2.0 (not 99.0.0) and refuses to mark itself healthy. Implementation needs a reliable "what version is this binary really" hook (compile-time `PERRY_VERSION` constant baked into the program, exposed to TS).
I'd lean toward B (manifest signing) since it's the strongest model and matches what Tauri converged on, but A is a reasonable v1.1 increment if the manifest-signing tooling is bigger than we want.
Acceptance criteria
Whichever option:
Out of scope