From c04952ca58c81df4bdda529688f91cb9c6f46364 Mon Sep 17 00:00:00 2001 From: "ren.ji" Date: Tue, 13 Oct 2026 00:44:34 +0800 Subject: [PATCH] Verify update manifests before parsing payloads --- CHANGELOG.md | 5 +++++ docs/release-and-updates.md | 4 +++- rivet/distribution/manifest.rkt | 7 +++++-- rivet/scribblings/rivet.scrbl | 8 +++++++- tests/distribution.rkt | 23 +++++++++++++++++++++++ 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 272653c5..3fc87cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ bounded, redirect-aware downloader. Interrupted or cancelled downloads remove their partial file as reliably as ordinary failures. +- Authenticate signed update-manifest payload bytes before parsing their JSON. + Malformed, unauthenticated payloads now fail at the Ed25519 boundary instead + of entering semantic validation and contradicting the documented trust + order. `read-signed-manifest` remains an explicitly unauthenticated + inspection API; update clients use `verify-signed-manifest`. - Add a Windows `rivet::system::TrayController` that owns tray window subclassing, localized native menu presentation, command dispatch, and automatic `TaskbarCreated` recovery. The reference Taskboard now exercises diff --git a/docs/release-and-updates.md b/docs/release-and-updates.md index b43ad97b..a49c43b5 100644 --- a/docs/release-and-updates.md +++ b/docs/release-and-updates.md @@ -65,7 +65,9 @@ run; the family update feed and locked-down machines consume it directly. HTTPS protects transport, but it is not the root of update trust. A manifest contains an exact byte payload and an Ed25519 signature. The signed payload identifies the application, version, build, stable/beta/dev channel, staged rollout percentage, minimum updatable version, rollback predecessor, and every platform artifact's URL, byte size, SHA-256, installer kind, and arguments. -The updater verifies in this order: +The updater verifies in this order. The signed payload stays as opaque bytes +until the Ed25519 check succeeds; unauthenticated input never reaches the +manifest JSON parser. 1. Ed25519 signature and expected key ID. 2. Application identity, channel, SemVer precedence, minimum version, rollout bucket, platform, and architecture. diff --git a/rivet/distribution/manifest.rkt b/rivet/distribution/manifest.rkt index 4fd5a2f2..097d0d0c 100644 --- a/rivet/distribution/manifest.rkt +++ b/rivet/distribution/manifest.rkt @@ -194,7 +194,10 @@ (values (payload-bytes->manifest payload) payload key-id signature)) (define (verify-signed-manifest input public-key #:key-id [expected-key-id #f]) - (define-values (manifest payload key-id signature) (read-signed-manifest input)) + ;; The payload is attacker-controlled until the signature succeeds. Parse + ;; only the small outer wrapper first, authenticate the exact payload bytes, + ;; and cross the semantic parsing boundary afterwards. + (define-values (payload key-id signature) (read-wrapper input)) (when (and expected-key-id (not (string=? expected-key-id key-id))) (raise-arguments-error 'verify-signed-manifest "manifest was signed by an unexpected key" @@ -202,4 +205,4 @@ "actual" key-id)) (unless (ed25519-verify public-key payload signature) (error 'verify-signed-manifest "Ed25519 manifest signature verification failed")) - manifest) + (payload-bytes->manifest payload)) diff --git a/rivet/scribblings/rivet.scrbl b/rivet/scribblings/rivet.scrbl index 3f99c4af..d76b3e8b 100644 --- a/rivet/scribblings/rivet.scrbl +++ b/rivet/scribblings/rivet.scrbl @@ -417,11 +417,17 @@ policy plus a list of artifacts. [out output-port? (current-output-port)]) void?]{ Validates, serializes, and signs a manifest payload with Ed25519.} +@defproc[(read-signed-manifest [input input-port?]) + (values update-manifest? bytes? string? bytes?)]{ +Decodes a signed wrapper for inspection and returns its parsed manifest, exact +payload bytes, key ID, and signature bytes. This procedure does not +authenticate them; update clients should use @racket[verify-signed-manifest].} @defproc[(verify-signed-manifest [input input-port?] [public-key any/c] [#:key-id expected-key-id (or/c #f string?) #f]) update-manifest?]{ -Verifies the exact signed payload before exposing the parsed manifest.} +Verifies the expected key ID and Ed25519 signature over the exact payload bytes +before parsing or exposing the manifest.} @subsection{Selecting and Installing an Update} diff --git a/tests/distribution.rkt b/tests/distribution.rkt index 9de5a36c..bccf9f51 100644 --- a/tests/distribution.rkt +++ b/tests/distribution.rkt @@ -111,6 +111,29 @@ (verify-signed-manifest (open-input-bytes (get-output-bytes tampered-out)) public-key))) + ;; Unauthenticated payload bytes never reach the JSON/manifest parser. A + ;; malformed payload with a well-formed but mismatched signature must fail at + ;; the cryptographic boundary, not disclose a downstream parse diagnostic. + (define untrusted-out (open-output-bytes)) + (write-json + (hasheq + 'schema manifest-schema-version + 'payload (bytes->base64-string #"not JSON") + 'signature + (hasheq + 'algorithm "ed25519" + 'key_id "release-2026" + 'value + (bytes->base64-string + (ed25519-sign private-key #"different authenticated payload")))) + untrusted-out) + (check-exn #rx"signature verification failed" + (lambda () + (verify-signed-manifest + (open-input-bytes (get-output-bytes untrusted-out)) + public-key + #:key-id "release-2026"))) + (define config (updater-config "dev.rivet.test" "1.1.0" 'stable 'windows 'x64 public-key "release-2026" 0 (* 1024 1024)))