Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/release-and-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
78 changes: 72 additions & 6 deletions rivet/distribution/manifest.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -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 symbol<?))))

(define (read-complete-json who input)
(define value (read-json input))
(define trailing (read-json input))
(unless (eof-object? trailing)
(raise-arguments-error who
"input must contain exactly one JSON value"))
value)

(define (required hash key predicate description)
(define value
(hash-ref hash key
Expand Down Expand Up @@ -75,6 +100,8 @@
(define (jsexpr->artifact 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)))
Expand All @@ -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)))
Expand All @@ -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
Expand Down Expand Up @@ -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 (version<? minimum-version version)
(raise-arguments-error
'payload-bytes->manifest
"minimum_version must be older than the release version"
"minimum_version" minimum-version
"version" version))
(when (and (not (eq? previous 'null))
(not (version<? previous version)))
(raise-arguments-error
'payload-bytes->manifest
"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 ""))))
Expand All @@ -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
Expand All @@ -169,16 +231,20 @@
(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
"unsupported signed wrapper schema"
"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
Expand Down
7 changes: 6 additions & 1 deletion rivet/scribblings/rivet.scrbl
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
46 changes: 46 additions & 0 deletions tests/distribution.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading