From d0fa7d89360957df763a13c94dff8725e7629007 Mon Sep 17 00:00:00 2001 From: jrtxio Date: Fri, 16 Oct 2026 18:32:30 +0800 Subject: [PATCH] Add the sibling-asset helper and the sign-manifest subcommand Three products hand-roll the same sibling-asset download and all three copies skip the HTTP status check, so a 404 page was saved as the payload and only failed later with a misleading checksum error. The shared fetch-sibling-asset fails fast on non-2xx statuses, enforces the declared Content-Length against the byte limit, verifies an optional expected SHA-256, and streams under the same deadlines as the manifest fetch. Two private signed-feed assembly paths (bash+OpenSSL and Racket) collapse into raco rivet sign-manifest: it builds the manifest from explicitly named artifacts, signs through the distribution signer, verifies the written bytes with the public half before publishing and deletes the output when verification fails, and optionally emits .sha256 sidecars. Products keep only their asset-naming conventions. --- CHANGELOG.md | 12 +++ rivet-cli/main.rkt | 18 ++++ rivet-cli/sign-manifest.rkt | 184 +++++++++++++++++++++++++++++++++ rivet/distribution/updater.rkt | 81 +++++++++++++++ rivet/tests/sign-manifest.rkt | 142 +++++++++++++++++++++++++ 5 files changed, 437 insertions(+) create mode 100644 rivet-cli/sign-manifest.rkt create mode 100644 rivet/tests/sign-manifest.rkt diff --git a/CHANGELOG.md b/CHANGELOG.md index de889f2b..31e292c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +- Add `fetch-sibling-asset` to `rivet/distribution` and the + `raco rivet sign-manifest` subcommand. The sibling helper checks the HTTP + status before saving, enforces the declared Content-Length against the + byte limit, verifies an optional expected SHA-256, and streams under the + same connect and idle deadlines as the manifest fetch — replacing the + private per-product sidecar downloads that silently accepted 404 pages. + The subcommand builds a manifest from explicitly named artifacts, signs + it with the distribution signer, verifies the written bytes with the + public half before publishing (deleting the output on failure), and + optionally emits `.sha256` sidecars next to each artifact, so products + keep only their asset-naming conventions. + - Extend the family boundary to Commerce: online payment orchestration and credential delivery remain optional product/service concerns and never become Rivet dependencies. diff --git a/rivet-cli/main.rkt b/rivet-cli/main.rkt index 036760f2..aa7309cc 100644 --- a/rivet-cli/main.rkt +++ b/rivet-cli/main.rkt @@ -50,6 +50,12 @@ " raco rivet release --development exercise release flow without platform production signing\n" " raco rivet release --without-updates release without an update manifest or update key\n" " raco rivet manifest-compose [--output ]\n" + " raco rivet sign-manifest --application-id --version --build \n" + " --channel --base-url \n" + " --key-id --artifact ::: ...\n" + " [--private-key ] [--public-key ] [--output ]\n" + " [--sidecars] [--minimum-version ] [--previous-version ]\n" + " [--published-at ] [--rollout <0-100>] [--rollback-allowed]\n" " compose a verified family update manifest\n" " raco rivet compliance generate SBOM/notices and run the license audit\n" " raco rivet verify re-verify the current packaged artifact\n" @@ -211,6 +217,18 @@ #:private-key-path private-key-path #:key-id key-id)) (say "family update manifest: ~a" compose-output)] + [(cons "sign-manifest" rest) + ;; Build the release update manifest from named artifacts, sign it + ;; with the distribution signer, and (with a public key available) + ;; verify the written bytes before publishing. Products keep only + ;; their asset-naming conventions. + (define parse-sign-manifest-arguments + (command "sign-manifest" 'parse-sign-manifest-arguments)) + (define-values (artifacts options) + (parse-sign-manifest-arguments rest)) + (define sign-manifest! (command "sign-manifest" 'sign-manifest!)) + (define output (sign-manifest! artifacts options)) + (say "signed update manifest: ~a" output)] [(list "verify") (define verify-project-package! (command "verify" 'verify-project-package!)) (define output (verify-project-package! (current-project!))) diff --git a/rivet-cli/sign-manifest.rkt b/rivet-cli/sign-manifest.rkt new file mode 100644 index 00000000..0f9238e0 --- /dev/null +++ b/rivet-cli/sign-manifest.rkt @@ -0,0 +1,184 @@ +#lang racket/base + +;; Builds the release update manifest from a dist directory, signs it with +;; the distribution signer, and refuses to publish anything unsigned or +;; unverified. Products keep only their asset-naming conventions: each +;; artifact is named explicitly on the command line, and the manifest URL is +;; the base URL joined with the file name. + +(require racket/file + racket/match + racket/path + racket/string + "../rivet/distribution/crypto.rkt" + "../rivet/distribution/manifest.rkt") + +(provide sign-manifest! + parse-sign-manifest-arguments + (struct-out requested-artifact)) + +(define installer-symbols '(zip msi deb rpm appimage)) + +(struct requested-artifact (platform architecture installer path) #:transparent) + +(define (parse-artifact-spec who spec) + (define parts (string-split spec ":")) + (unless (= (length parts) 4) + (raise-arguments-error who + "--artifact expects :::" + "value" spec)) + (define platform (string->symbol (list-ref parts 0))) + (define architecture (string->symbol (list-ref parts 1))) + (define installer (string->symbol (list-ref parts 2))) + (unless (memq platform '(windows macos linux)) + (raise-arguments-error who "artifact platform must be windows, macos, or linux" + "value" spec)) + (unless (memq architecture '(x64 arm64)) + (raise-arguments-error who "artifact architecture must be x64 or arm64" + "value" spec)) + (unless (memq installer installer-symbols) + (raise-arguments-error + who + "artifact installer must be zip, msi, deb, rpm, or appimage" + "value" spec)) + (requested-artifact platform architecture installer (list-ref parts 3))) + +(define (parse-sign-manifest-arguments arguments) + (let loop ([remaining arguments] + [artifacts '()] + [options (hasheq)]) + (define (with key tail value) + (if (hash-has-key? options key) + (raise-arguments-error 'sign-manifest + (format "~a may be specified only once" key)) + (loop tail artifacts (hash-set options key value)))) + (match remaining + ['() + (values (reverse artifacts) options)] + [(list* "--application-id" value tail) (with 'application-id tail value)] + [(list* "--version" value tail) (with 'version tail value)] + [(list* "--build" value tail) + (define number (string->number value)) + (unless number + (raise-arguments-error 'sign-manifest "--build requires an integer" "value" value)) + (loop tail artifacts (hash-set options 'build number))] + [(list* "--channel" value tail) + (unless (memq (string->symbol value) '(stable beta dev)) + (raise-arguments-error 'sign-manifest + "--channel must be stable, beta, or dev" + "value" value)) + (with 'channel tail (string->symbol value))] + [(list* "--base-url" value tail) + (unless (and (string? value) (regexp-match? #px"^https://" value)) + (raise-arguments-error 'sign-manifest + "--base-url must be an HTTPS URL" + "value" value)) + (with 'base-url tail value)] + [(list* "--key-id" value tail) (with 'key-id tail value)] + [(list* "--private-key" value tail) (with 'private-key tail value)] + [(list* "--public-key" value tail) (with 'public-key tail value)] + [(list* "--output" value tail) (with 'output tail value)] + [(list* "--minimum-version" value tail) (with 'minimum-version tail value)] + [(list* "--previous-version" value tail) (with 'previous-version tail value)] + [(list* "--published-at" value tail) (with 'published-at tail value)] + [(list* "--rollout" value tail) + (define number (string->number value)) + (unless (and number (integer? number) (>= number 0) (<= number 100)) + (raise-arguments-error 'sign-manifest + "--rollout requires an integer between 0 and 100" + "value" value)) + (loop tail artifacts (hash-set options 'rollout number))] + [(list "--rollback-allowed") (loop artifacts (hash-set options 'rollback-allowed? #t))] + [(list "--sidecars") (loop artifacts (hash-set options 'sidecars? #t))] + [(list* "--artifact" spec tail) + (loop tail (cons (parse-artifact-spec 'sign-manifest spec) artifacts) options)] + [(cons argument _) + (raise-arguments-error 'sign-manifest "unknown argument" "argument" argument)]))) + +(define (require-option who options key) + (or (hash-ref options key #f) + (raise-arguments-error who + (format "~a is required" key)))) + +(define (join-url base-url file-name) + (format "~a/~a" + (string-trim base-url "/" #:right? #t) + file-name)) + +(define (write-sidecar! artifact-path) + (define sidecar (path-add-extension artifact-path #".sha256")) + (call-with-output-file sidecar + #:exists 'truncate/replace + (lambda (out) + (fprintf out "~a ~a\n" + (sha256-file/hex artifact-path) + (path->string (file-name-from-path artifact-path))))) + sidecar) + +(define (sign-manifest! artifacts options) + (define application-id (require-option 'sign-manifest options 'application-id)) + (define version (require-option 'sign-manifest options 'version)) + (define build (require-option 'sign-manifest options 'build)) + (define channel (require-option 'sign-manifest options 'channel)) + (define base-url (require-option 'sign-manifest options 'base-url)) + (define key-id (require-option 'sign-manifest options 'key-id)) + (define output + (string->path + (or (hash-ref options 'output #f) + "dist/update-manifest.json"))) + (when (null? artifacts) + (raise-arguments-error 'sign-manifest + "at least one --artifact is required")) + (define private-key-path + (or (hash-ref options 'private-key #f) + (getenv "RIVET_UPDATE_PRIVATE_KEY") + (raise-arguments-error 'sign-manifest + "a private key is required: pass --private-key or set RIVET_UPDATE_PRIVATE_KEY"))) + (define private-key (read-ed25519-private-key private-key-path)) + (define manifest + (update-manifest + application-id version build channel + (or (hash-ref options 'published-at #f) + (number->string (current-seconds))) + (hash-ref options 'minimum-version "0.0.0") + (hash-ref options 'previous-version #f) + (hash-ref options 'rollback-allowed? #f) + (hash-ref options 'rollout 100) + (for/list ([artifact (in-list artifacts)]) + (define path (string->path (requested-artifact-path artifact))) + (unless (file-exists? path) + (raise-arguments-error 'sign-manifest + "artifact file does not exist" + "file" path)) + (update-artifact + (requested-artifact-platform artifact) + (requested-artifact-architecture artifact) + (join-url base-url (path->string (file-name-from-path path))) + (sha256-file/hex path) + (file-size path) + (requested-artifact-installer artifact) + '())))) + (make-parent-directory* output) + (call-with-output-file output + #:exists 'truncate/replace + (lambda (out) (write-signed-manifest manifest private-key key-id out))) + ;; Fail-closed publish: read the written file back and verify the exact + ;; bytes with the public half when one is available. A failed verification + ;; deletes the output so an unverified feed cannot be published by accident. + (define public-key-path + (or (hash-ref options 'public-key #f) + (getenv "RIVET_UPDATE_PUBLIC_KEY"))) + (when public-key-path + (with-handlers ([exn:fail? + (lambda (error) + (when (file-exists? output) (delete-file output)) + (raise error))]) + (call-with-input-file output + (lambda (in) + (verify-signed-manifest in + (read-ed25519-public-key public-key-path) + #:key-id key-id))))) + (when (hash-ref options 'sidecars? #f) + (for ([artifact (in-list artifacts)]) + (write-sidecar! (string->path (requested-artifact-path artifact))))) + output) diff --git a/rivet/distribution/updater.rkt b/rivet/distribution/updater.rkt index caf75fd6..fd100ff2 100644 --- a/rivet/distribution/updater.rkt +++ b/rivet/distribution/updater.rkt @@ -15,6 +15,7 @@ (struct-out update-candidate) (struct-out install-plan) fetch-update-manifest + fetch-sibling-asset select-update download-update verify-update-artifact! @@ -179,6 +180,86 @@ #:key-id key-id)) (lambda () (close-input-port in)))))) +;; Release sidecars and sibling artifacts (sha256 files, extra installers) +;; live next to the update feed on the same host. Every private copy of +;; this helper skipped the HTTP status check, so a 404 page was saved as +;; the payload and only failed later with a misleading checksum error. +;; This shared helper fails fast instead: non-2xx statuses name the URL and +;; status, Content-Length mismatches are rejected, and the download runs +;; under the same deadlines as the manifest fetch. +(define (fetch-sibling-asset url destination + #:sha256 [expected-sha256 #f] + #:maximum-bytes [maximum-bytes (* 1024 1024 1024)] + #:on-progress [on-progress void] + #:connect-timeout-seconds + [connect-timeout-seconds + update-fetch-connect-timeout-seconds] + #:idle-timeout-seconds + [idle-timeout-seconds + update-fetch-idle-timeout-seconds]) + (unless (and (string? url) (regexp-match? #px"^https://" url)) + (raise-argument-error 'fetch-sibling-asset "HTTPS URL string" url)) + (unless (real? maximum-bytes) + (raise-argument-error 'fetch-sibling-asset "real?" maximum-bytes)) + (call-with-fetch-deadline + 'fetch-sibling-asset connect-timeout-seconds idle-timeout-seconds + (lambda () + (define-values (response in) + (get-pure-port/headers + (string->url url) + '("User-Agent: Rivet-Updater/1") + #:redirections update-fetch-redirections)) + (dynamic-wind + void + (lambda () + ;; The status line is the first response header: "HTTP/1.1 200 OK". + (define status-line (read-line in 'return)) + (define status + (let ([match (regexp-match #rx"^HTTP/[0-9.]+ ([0-9]{3})" status-line)]) + (and match (string->number (list-ref match 1))))) + (unless (and status (<= 200 status 299)) + (error 'fetch-sibling-asset + "sibling asset request failed: HTTP ~a for ~a" + (or status "unknown") url)) + ;; Consume the header block; only Content-Length is load-bearing. + (define content-length + (let loop () + (define line (read-line in 'return)) + (cond + [(or (eof-object? line) (string=? line "")) #f] + [(regexp-match #rx"(?i:^content-length:[ ]*([0-9]+)[ ]*$)" line) + => (lambda (match) (string->number (list-ref match 1)))] + [else (loop)]))) + (when (exact-positive-integer? content-length) + (when (> content-length maximum-bytes) + (error 'fetch-sibling-asset + "sibling asset declares ~a bytes beyond the ~a byte limit" + content-length maximum-bytes))) + (make-parent-directory* destination) + (define temporary (path-add-extension destination #".partial")) + (when (file-exists? temporary) (delete-file temporary)) + (call-with-partial-cleanup + temporary + (lambda () + (call-with-output-file temporary + #:exists 'truncate/replace + #:mode 'binary + (lambda (out) + (copy-limited! in out maximum-bytes + #:who 'fetch-sibling-asset + #:on-progress on-progress + #:stall-seconds idle-timeout-seconds))) + (when expected-sha256 + (define actual (sha256-file/hex temporary)) + (unless (string=? actual expected-sha256) + (error 'fetch-sibling-asset + "sibling asset SHA-256 does not match the expected value" + "expected" expected-sha256 + "actual" actual))) + (rename-file-or-directory temporary destination #t) + destination))) + (lambda () (close-input-port in)))))) + (define install-kinds '(portable msi appimage package-manager)) (define (artifact-matches-install-kind? artifact install-kind) diff --git a/rivet/tests/sign-manifest.rkt b/rivet/tests/sign-manifest.rkt new file mode 100644 index 00000000..78e20b74 --- /dev/null +++ b/rivet/tests/sign-manifest.rkt @@ -0,0 +1,142 @@ +#lang racket/base + +;; Sign-manifest CLI end to end: keygen, sign from named artifacts, verify +;; with the public half, sidecar emission, and the fail-closed publish that +;; deletes the output when verification fails. + +(require rackunit + racket/file + racket/path + crypto + crypto/all + "../../rivet/distribution/crypto.rkt" + "../../rivet/distribution/manifest.rkt" + "../../rivet-cli/sign-manifest.rkt") + +(use-all-factories!) + +(define ed25519-impl + (get-pk 'eddsa (list libcrypto-factory sodium-factory decaf-factory))) +(define private-key + (and ed25519-impl + (with-handlers ([exn:fail? (lambda (_) #f)]) + (generate-private-key ed25519-impl '((curve ed25519)))))) + +(unless private-key + (displayln + "rivet-sign-manifest-tests: skipping; no compatible Ed25519 provider") + (exit 0)) + +(define root (make-temporary-file "rivet-sign-manifest-~a" 'directory)) +(define private-key-path (build-path root "private.der")) +(define public-key-path (build-path root "public.der")) +(call-with-output-file private-key-path + #:exists 'truncate/replace + (lambda (out) (write-bytes (pk-key->datum private-key 'PrivateKeyInfo) out))) +(call-with-output-file public-key-path + #:exists 'truncate/replace + (lambda (out) + (write-bytes (pk-key->datum private-key 'SubjectPublicKeyInfo) out))) + +;; --- argument parsing ------------------------------------------------------ + +(define-values (parsed-artifacts parsed-options) + (parse-sign-manifest-arguments + (list "--application-id" "dev.example" + "--version" "1.2.3" "--build" "7" "--channel" "stable" + "--base-url" "https://dl.example/app/" + "--key-id" "release-2026" + "--artifact" "macos:arm64:zip:App-1.2.3.zip"))) +(check-equal? (length parsed-artifacts) 1) +(check-equal? (hash-ref parsed-options 'application-id) "dev.example") +(check-equal? (hash-ref parsed-options 'build) 7) +(check-equal? (hash-ref parsed-options 'channel) 'stable) + +(check-exn #rx"--artifact expects" + (lambda () (parse-sign-manifest-arguments (list "--artifact" "macos:arm64:zip")))) +(check-exn #rx"artifact installer must be" + (lambda () (parse-sign-manifest-arguments (list "--artifact" "macos:arm64:exe:App.zip")))) +(check-exn #rx"--base-url must be an HTTPS URL" + (lambda () (parse-sign-manifest-arguments (list "--base-url" "http://dl.example/")))) +(check-exn #rx"unknown argument" + (lambda () (parse-sign-manifest-arguments (list "--bogus")))) +(check-exn #rx"at least one --artifact" + (lambda () + (sign-manifest! '() + (hasheq 'application-id "d" 'version "1" 'build 1 + 'channel 'stable 'base-url "https://x/" + 'key-id "k" 'private-key (path->string private-key-path))))) + +;; --- signing end to end ---------------------------------------------------- + +(define artifact (build-path root "App-1.2.3-macos-arm64.zip")) +(call-with-output-file artifact + #:exists 'truncate/replace + (lambda (out) (write-bytes (make-bytes 2048 7) out))) + +(define output + (sign-manifest! + (list (requested-artifact 'macos 'arm64 'zip (path->string artifact))) + (hasheq 'application-id "dev.example" + 'version "1.2.3" + 'build 7 + 'channel 'stable + 'base-url "https://dl.example/app/" + 'key-id "release-2026" + 'private-key (path->string private-key-path) + 'public-key (path->string public-key-path) + 'output (path->string (build-path root "update.json")) + 'sidecars? #t + 'published-at "2026-10-16T00:00:00Z"))) +(check-true (file-exists? output)) + +;; The published feed verifies with the public half and carries the exact +;; artifact facts. +(define manifest + (call-with-input-file output + (lambda (in) (verify-signed-manifest in (read-ed25519-public-key public-key-path))))) +(check-equal? (update-manifest-application-id manifest) "dev.example") +(check-equal? (length (update-manifest-artifacts manifest)) 1) +(define entry (car (update-manifest-artifacts manifest))) +(check-equal? (update-artifact-url entry) + (format "https://dl.example/app/~a" + (path->string (file-name-from-path artifact)))) +(check-equal? (update-artifact-size entry) 2048) +(check-equal? (update-artifact-sha256 entry) (sha256-file/hex artifact)) + +;; Sidecars landed next to the artifact in bsdtar's conventional format. +(define sidecar (path-add-extension artifact #".sha256")) +(check-true (file-exists? sidecar)) +(check-regexp-match + #rx"^[0-9a-f]{64} App-1\\.2\\.3-macos-arm64\\.zip$" + (file->string sidecar)) + +;; --- fail-closed publish --------------------------------------------------- + +;; A wrong public key deletes the output instead of leaving an unverified +;; feed on disk. +(define wrong-public (build-path root "wrong.der")) +(call-with-output-file wrong-public + #:exists 'truncate/replace + (lambda (out) (write-bytes (make-bytes 44 3) out))) +(define other-artifact (build-path root "Other-1.0.0-linux-x64.zip")) +(call-with-output-file other-artifact + #:exists 'truncate/replace + (lambda (out) (write-bytes (make-bytes 128 9) out))) +(define other-output (build-path root "other-update.json")) +(check-exn #rx"verification failed|unexpected key|malformed" + (lambda () + (sign-manifest! + (list (requested-artifact 'linux 'x64 'zip (path->string other-artifact))) + (hasheq 'application-id "dev.example" + 'version "1.0.0" + 'build 1 + 'channel 'stable + 'base-url "https://dl.example/other/" + 'key-id "release-2026" + 'private-key (path->string private-key-path) + 'public-key (path->string wrong-public) + 'output (path->string other-output))))) +(check-false (file-exists? other-output)) + +(delete-directory/files root)