diff --git a/CHANGELOG.md b/CHANGELOG.md index de889f2..31e292c 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 036760f..aa7309c 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 0000000..0f9238e --- /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 caf75fd..fd100ff 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 0000000..78e20b7 --- /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)