From 89fabf8f2a11c01a7c323c1f45b41061ac2ed2b3 Mon Sep 17 00:00:00 2001 From: "ren.ji" Date: Tue, 13 Oct 2026 08:30:12 +0800 Subject: [PATCH] Reject contradictory update manifests --- CHANGELOG.md | 7 +++ docs/release-and-updates.md | 8 ++++ rivet/distribution/manifest.rkt | 78 ++++++++++++++++++++++++++++++--- rivet/scribblings/rivet.scrbl | 7 ++- tests/distribution.rkt | 46 +++++++++++++++++++ 5 files changed, 139 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc87cb..54c2279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- Reject self-contradictory signed update manifests before publication or + client acceptance: artifact sets must be non-empty and positive-size, + minimum/previous versions must precede the release, and artifact + platform/architecture/installer identities must be unique. Signed wrappers + and payloads also reject trailing JSON values and fields outside their + versioned schemas. + - `download-update` reports download progress: an optional `#:on-progress` callback receives cumulative bytes from zero through the final downloaded size. Intermediate reports are throttled to about one per percent of the diff --git a/docs/release-and-updates.md b/docs/release-and-updates.md index a49c43b..0a998db 100644 --- a/docs/release-and-updates.md +++ b/docs/release-and-updates.md @@ -65,6 +65,14 @@ 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. +Manifest policy is validated as a whole before signing or accepting it. Every +release has at least one positive-size artifact; minimum and previous versions +are older than the release; and platform/architecture/installer identities are +unique. Separate portable and AppImage artifacts for the same Linux target are +valid because their installer identities differ. Versioned wrapper, manifest, +signature, and artifact objects are closed schemas; unknown fields and trailing +JSON values fail instead of being silently ignored. + 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. diff --git a/rivet/distribution/manifest.rkt b/rivet/distribution/manifest.rkt index 097d0d0..afa0f79 100644 --- a/rivet/distribution/manifest.rkt +++ b/rivet/distribution/manifest.rkt @@ -28,6 +28,31 @@ previous-version rollback-allowed? rollout artifacts) #:transparent) +(define manifest-fields + '(schema application_id version build channel published_at minimum_version + previous_version rollback_allowed rollout artifacts)) +(define artifact-fields + '(platform architecture url sha256 size installer arguments)) +(define wrapper-fields '(schema payload signature)) +(define signature-fields '(algorithm key_id value)) + +(define (reject-unknown-fields who value allowed context) + (define unknown + (filter (lambda (key) (not (memq key allowed))) (hash-keys value))) + (unless (null? unknown) + (raise-arguments-error who + "object contains fields outside its schema" + "object" context + "fields" (sort unknown symbolartifact value) (unless (hash? value) (raise-argument-error 'payload-bytes->manifest "artifact object" value)) + (reject-unknown-fields + 'payload-bytes->manifest value artifact-fields "artifact") (define sha (required value 'sha256 (lambda (v) (and (string? v) (regexp-match? #px"^[0-9A-Fa-f]{64}$" v))) @@ -87,7 +114,7 @@ (regexp-match? #px"^https://" v))) "HTTPS URL") (string-downcase sha) - (required value 'size exact-nonnegative-integer? "non-negative integer") + (required value 'size exact-positive-integer? "positive integer") (string->symbol (required value 'installer string? "string")) (required value 'arguments (lambda (v) (and (list? v) (andmap string? v))) @@ -100,9 +127,12 @@ (raise-arguments-error 'payload-bytes->manifest "payload is not valid JSON" "detail" (exn-message e)))]) - (read-json (open-input-bytes payload)))) + (read-complete-json + 'payload-bytes->manifest (open-input-bytes payload)))) (unless (hash? value) (raise-argument-error 'payload-bytes->manifest "JSON object payload" value)) + (reject-unknown-fields + 'payload-bytes->manifest value manifest-fields "manifest") (define schema (required value 'schema exact-integer? "integer")) (unless (= schema manifest-schema-version) (raise-arguments-error 'payload-bytes->manifest @@ -130,6 +160,39 @@ (raise-arguments-error 'payload-bytes->manifest "previous_version must be null or SemVer" "value" previous)) + (define minimum-version + (required value 'minimum_version version? "SemVer 2.0 string")) + (unless (versionmanifest + "minimum_version must be older than the release version" + "minimum_version" minimum-version + "version" version)) + (when (and (not (eq? previous 'null)) + (not (versionmanifest + "previous_version must be older than the release version" + "previous_version" previous + "version" version)) + (define artifact-values + (required value 'artifacts + (lambda (v) (and (list? v) (pair? v))) + "non-empty array")) + (define artifacts (map jsexpr->artifact artifact-values)) + (define duplicate-identity + (check-duplicates + (map (lambda (artifact) + (list (update-artifact-platform artifact) + (update-artifact-architecture artifact) + (update-artifact-installer artifact))) + artifacts) + equal?)) + (when duplicate-identity + (raise-arguments-error + 'payload-bytes->manifest + "duplicate artifact platform, architecture, and installer identity" + "identity" duplicate-identity)) (update-manifest (required value 'application_id (lambda (v) (and (string? v) (not (string=? v "")))) @@ -142,12 +205,11 @@ (and (string? v) (regexp-match? #px"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" v))) "UTC RFC 3339 timestamp") - (required value 'minimum_version version? "SemVer 2.0 string") + minimum-version (and (not (eq? previous 'null)) previous) (required value 'rollback_allowed boolean? "boolean") rollout - (map jsexpr->artifact - (required value 'artifacts list? "array")))) + artifacts)) (define (signed-wrapper payload key-id signature) (hasheq 'schema manifest-schema-version @@ -169,9 +231,11 @@ (newline out)) (define (read-wrapper input) - (define value (read-json input)) + (define value (read-complete-json 'read-signed-manifest input)) (unless (hash? value) (raise-argument-error 'read-signed-manifest "JSON object" value)) + (reject-unknown-fields + 'read-signed-manifest value wrapper-fields "signed wrapper") (define schema (required value 'schema exact-integer? "integer")) (unless (= schema manifest-schema-version) (raise-arguments-error 'read-signed-manifest @@ -179,6 +243,8 @@ "configured" schema "supported" manifest-schema-version)) (define signature (required value 'signature hash? "object")) + (reject-unknown-fields + 'read-signed-manifest signature signature-fields "signature") (define algorithm (required signature 'algorithm string? "string")) (unless (string=? algorithm "ed25519") (raise-arguments-error 'read-signed-manifest diff --git a/rivet/scribblings/rivet.scrbl b/rivet/scribblings/rivet.scrbl index d76b3e8..484e2db 100644 --- a/rivet/scribblings/rivet.scrbl +++ b/rivet/scribblings/rivet.scrbl @@ -409,7 +409,12 @@ Checks whether a version is allowed by a release channel.} @racket[platform], @racket[architecture], @racket[url], @racket[sha256], @racket[size], @racket[installer], and @racket[arguments]. @racket[update-manifest] values contain the signed application and release -policy plus a list of artifacts. +policy plus a non-empty list of artifacts. The minimum updatable version and +optional rollback predecessor must both be older than the release version. +Artifact sizes are positive, and a manifest cannot repeat the same platform, +architecture, and installer identity. Payloads and signed wrappers contain +exactly one JSON value and reject fields outside their versioned schema, so a +publisher typo cannot be silently ignored. @defproc[(write-signed-manifest [manifest update-manifest?] [private-key any/c] diff --git a/tests/distribution.rkt b/tests/distribution.rkt index bccf9f5..3e34576 100644 --- a/tests/distribution.rkt +++ b/tests/distribution.rkt @@ -81,6 +81,52 @@ "unused" #:progress-every 0))) +;; A signed policy must describe at least one possible upgrade path. Reject +;; contradictions while parsing and, through write-signed-manifest's round +;; trip, before a publisher can sign them. +(define (check-invalid-manifest manifest expected-message) + (check-exn expected-message + (lambda () + (payload-bytes->manifest + (manifest->payload-bytes manifest))))) + +(check-invalid-manifest + (struct-copy update-manifest sample-manifest [artifacts '()]) + #rx"non-empty array") +(check-invalid-manifest + (struct-copy update-manifest sample-manifest [minimum-version "1.2.0"]) + #rx"minimum_version must be older") +(check-invalid-manifest + (struct-copy update-manifest sample-manifest [previous-version "1.2.0"]) + #rx"previous_version must be older") +(check-invalid-manifest + (struct-copy update-manifest sample-manifest + [artifacts + (list sample-artifact + (struct-copy update-artifact sample-artifact + [url "https://mirror.example/app.msi"]))]) + #rx"duplicate artifact") +(check-invalid-manifest + (struct-copy update-manifest sample-manifest + [artifacts + (list (struct-copy update-artifact sample-artifact [size 0]))]) + #rx"positive integer") + +(define sample-payload-jsexpr + (read-json (open-input-bytes (manifest->payload-bytes sample-manifest)))) +(define unknown-field-out (open-output-bytes)) +(write-json (hash-set sample-payload-jsexpr 'versoin "1.2.0") + unknown-field-out) +(check-exn #rx"fields outside its schema" + (lambda () + (payload-bytes->manifest + (get-output-bytes unknown-field-out)))) +(check-exn #rx"exactly one JSON value" + (lambda () + (payload-bytes->manifest + (bytes-append (manifest->payload-bytes sample-manifest) + #" {}")))) + (when private-key (define public-key (datum->pk-key (pk-key->datum private-key 'SubjectPublicKeyInfo)