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
12 changes: 11 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,20 @@ jobs:
shell: bash
run: |
set -euo pipefail
if [[ "$(git cat-file -t "$GITHUB_REF_NAME")" != "tag" ]]; then
# actions/checkout resolves a tag event to GITHUB_SHA and may replace
# the local refs/tags/<name> with that peeled commit. Fetch the real
# remote tag into a private namespace before inspecting its object.
release_tag_ref="refs/release-tags/$GITHUB_REF_NAME"
git fetch --force origin \
"refs/tags/$GITHUB_REF_NAME:$release_tag_ref"
if [[ "$(git cat-file -t "$release_tag_ref")" != "tag" ]]; then
echo "release tag must be annotated" >&2
exit 1
fi
if [[ "$(git rev-parse "$release_tag_ref^{}")" != "$GITHUB_SHA" ]]; then
echo "release tag does not point at the workflow commit" >&2
exit 1
fi
git fetch origin main
if ! git merge-base --is-ancestor HEAD origin/main; then
echo "release commit must be part of origin/main" >&2
Expand Down
14 changes: 4 additions & 10 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [0.7.0] - 2026-09-29

### Added
- **`glaze/signing` — Ed25519 artifact signing for self-updating apps**
(openssl CLI, zero compiled dependencies): `signing-keygen` (optional
AES-256-CBC private-key encryption), `sign-file` (Ed25519 over the
artifact's sha256 digest, base64 output), `verify-signature`, plus
`raco glaze updater-keygen` / `update-sign` / `update-verify` commands.
This is the Tauri-updater trust property: the signing key never lives on
the distribution server, and the public key pins inside the installed
app — a compromised release server cannot publish an accepted malicious
update. 14 tests cover roundtrip, tamper, wrong-key and encrypted-keygen
cases.
- Add `glaze/signing` and `raco glaze updater-keygen`, `update-sign`, and
`update-verify` for Ed25519-signed update artifacts. Private keys may be
password-encrypted, the installed application can pin the public key, and
signatures cover the artifact's SHA-256 digest without loading it in memory.

### Fixed
- Build the Scribble reference manual in CI and group modules that share a
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ raco glaze init <name> # Create a native Glaze desktop project
raco glaze dev # Run this project's native desktop app
raco glaze build # Build a distributable (exe + bundled assets)
raco glaze keygen # Create an RSA keypair for license signing
raco glaze updater-keygen # Create an Ed25519 update-signing keypair
raco glaze update-sign # Sign an update artifact
raco glaze update-verify # Verify an artifact and pinned-key signature
raco glaze license # Sign or verify offline license files
raco glaze help # Show help
```
Expand Down Expand Up @@ -172,6 +175,17 @@ Failure reasons are stable tags (`missing-file`, `malformed`, `signature`, `prod
(verify-file-sha256 artifact (hash-ref info 'sha256))
```

For publisher authenticity as well as download integrity, pin an Ed25519
public key inside the application and verify the release signature before
installing it:

```bash
raco glaze updater-keygen --out updater-keys
raco glaze update-sign app.zip --key updater-keys/private.pem --out app.zip.sig
raco glaze update-verify app.zip --pub updater-keys/public.pem \
--signature app.zip.sig --sha256 <manifest-sha256>
```

## Project Structure

A new Glaze project looks like this:
Expand Down
63 changes: 50 additions & 13 deletions glaze-cli/cli.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,11 @@

;; ---- updater artifact signing (Ed25519) -------------------------------------

(define (require-option-value who option args)
(unless (pair? (cdr args))
(error who "~a requires a value" option))
(cadr args))

(define (updater-sign-command rest)
(let loop ([args rest] [artifact #f] [key #f] [password #f] [out #f])
(cond
Expand All @@ -331,11 +336,24 @@
(lambda () (displayln sig))
#:exists 'replace)
(printf "signed ~a -> ~a\n" artifact out)]
[(equal? (car args) "--artifact") (loop (cdr args) (cadr args) key password out)]
[(equal? (car args) "--key") (loop (cdr args) artifact (cadr args) password out)]
[(equal? (car args) "--password") (loop (cdr args) artifact key (cadr args) out)]
[(equal? (car args) "--out") (loop (cdr args) artifact key password (cadr args))]
[else (loop (cdr args) (or artifact (car args)) key password out)])))
[(equal? (car args) "--artifact")
(loop (cddr args)
(require-option-value 'update-sign "--artifact" args)
key password out)]
[(equal? (car args) "--key")
(loop (cddr args) artifact
(require-option-value 'update-sign "--key" args)
password out)]
[(equal? (car args) "--password")
(loop (cddr args) artifact key
(require-option-value 'update-sign "--password" args)
out)]
[(equal? (car args) "--out")
(loop (cddr args) artifact key password
(require-option-value 'update-sign "--out" args))]
[artifact
(error 'update-sign "unexpected argument: ~a" (car args))]
[else (loop (cdr args) (car args) key password out)])))

(define (updater-verify-command rest)
(let loop ([args rest] [artifact #f] [pub #f] [signature #f] [sha256 #f])
Expand All @@ -355,11 +373,24 @@
(begin (printf "VALID\n")
(exit 0))
(begin (printf "INVALID\n") (exit 1)))]
[(equal? (car args) "--artifact") (loop (cdr args) (cadr args) pub signature sha256)]
[(equal? (car args) "--pub") (loop (cdr args) artifact (cadr args) signature sha256)]
[(equal? (car args) "--signature") (loop (cdr args) artifact pub (cadr args) sha256)]
[(equal? (car args) "--sha256") (loop (cdr args) artifact pub signature (cadr args))]
[else (loop (cdr args) (or artifact (car args)) pub signature sha256)])))
[(equal? (car args) "--artifact")
(loop (cddr args)
(require-option-value 'update-verify "--artifact" args)
pub signature sha256)]
[(equal? (car args) "--pub")
(loop (cddr args) artifact
(require-option-value 'update-verify "--pub" args)
signature sha256)]
[(equal? (car args) "--signature")
(loop (cddr args) artifact pub
(require-option-value 'update-verify "--signature" args)
sha256)]
[(equal? (car args) "--sha256")
(loop (cddr args) artifact pub signature
(require-option-value 'update-verify "--sha256" args))]
[artifact
(error 'update-verify "unexpected argument: ~a" (car args))]
[else (loop (cdr args) (car args) pub signature sha256)])))

(define (updater-keygen-command rest)
(let loop ([args rest] [out "updater-keys"] [password #f])
Expand All @@ -373,9 +404,15 @@
(printf "Updater Ed25519 keypair written:\n private: ~a (keep secret — signs update artifacts)\n public: ~a (pin inside the app — verifies update artifacts)\n"
priv pub)
(printf "public key fingerprint: ~a\n" (public-key-fingerprint pub))]
[(equal? (car args) "--out") (loop (cdr args) (cadr args) password)]
[(equal? (car args) "--password") (loop (cdr args) out (cadr args))]
[else (loop (cdr args) out password)])))
[(equal? (car args) "--out")
(loop (cddr args)
(require-option-value 'updater-keygen "--out" args)
password)]
[(equal? (car args) "--password")
(loop (cddr args) out
(require-option-value 'updater-keygen "--password" args))]
[else
(error 'updater-keygen "unexpected argument: ~a" (car args))])))

;; Dispatch CLI commands
(define args (vector->list (current-command-line-arguments)))
Expand Down
193 changes: 99 additions & 94 deletions glaze-test/signing-test.rkt
Original file line number Diff line number Diff line change
@@ -1,111 +1,116 @@
#lang racket/base

;; Ed25519 artifact signing: roundtrip, tamper detection, wrong-key
;; rejection, password-encrypted keygen. These are the primitives a
;; self-updating app pins its updater public key on — a false accept here
;; is a remote-code-execution vector, so the negative cases matter as much
;; as the positive one.

(require rackunit
racket/file
racket/runtime-path
racket/string
racket/system
glaze/signing)

(define dir (make-temporary-file "glaze-signing-test~a" 'directory))
(define-values (priv pub)
(signing-keygen #:private-key (build-path dir "private.pem")
#:public-key (build-path dir "public.pem")))

;; ---- roundtrip ---------------------------------------------------------------

(define artifact (build-path dir "artifact.bin"))
(call-with-output-file artifact
(lambda (out) (write-bytes (make-bytes 100000 7) out)))
(define directory (make-temporary-file "glaze-signing-test~a" 'directory))

(define signature (sign-file artifact #:private-key priv))
(check-true (string? signature) "signature is base64 text")
(check-false (string-contains? signature "\n") "signature is single-line")
(check-true (verify-signature artifact
#:public-key pub
#:signature signature)
"correct signature verifies")
(dynamic-wind
void
(lambda ()
(define-values (private-key public-key)
(signing-keygen
#:private-key (build-path directory "private.pem")
#:public-key (build-path directory "public.pem")))

;; empty artifact edge case
(define empty-artifact (build-path dir "empty.bin"))
(call-with-output-file empty-artifact (lambda (_) (void)))
(check-true (verify-signature empty-artifact
#:public-key pub
#:signature (sign-file empty-artifact #:private-key priv))
"empty artifact roundtrip")
(define artifact (build-path directory "artifact.bin"))
(call-with-output-file artifact
(lambda (output) (write-bytes (make-bytes 100000 7) output)))

;; ---- tamper / wrong key ------------------------------------------------------
(define signature (sign-file artifact #:private-key private-key))
(check-true (string? signature))
(check-false (string-contains? signature "\n"))
(check-true
(verify-signature artifact
#:public-key public-key
#:signature signature))

;; flipped content byte -> signature must fail (integrity)
(call-with-output-file artifact
(lambda (out) (write-bytes (make-bytes 100000 8)))
#:exists 'truncate)
(check-false (verify-signature artifact
#:public-key pub
#:signature signature)
"tampered artifact fails signature")
(define empty-artifact (build-path directory "empty.bin"))
(call-with-output-file empty-artifact (lambda (_) (void)))
(check-true
(verify-signature
empty-artifact
#:public-key public-key
#:signature (sign-file empty-artifact #:private-key private-key)))

;; restore, then assert the expected-digest gate raises on mismatch —
;; callers verify sha256 (from the manifest) before the signature
(call-with-output-file artifact
(lambda (out) (write-bytes (make-bytes 100000 7) out))
#:exists 'truncate)
(check-exn exn:fail?
(lambda ()
(verify-signature artifact
#:public-key pub
#:signature signature
#:expected-sha256 "00"))
"sha256 mismatch raises (caller bug or active tampering)")
(check-true (verify-signature artifact
#:public-key pub
#:signature signature
#:expected-sha256 (sha256-file artifact))
"matching expected sha256 verifies")
(call-with-output-file artifact
(lambda (output) (write-bytes (make-bytes 100000 8) output))
#:exists 'truncate)
(check-false
(verify-signature artifact
#:public-key public-key
#:signature signature))

;; a signature from a DIFFERENT key must not verify (authenticity)
(define-values (other-priv other-pub)
(signing-keygen #:private-key (build-path dir "other.pem")
#:public-key (build-path dir "other-pub.pem")))
(check-false (verify-signature artifact
#:public-key other-pub
#:signature signature)
"signature does not verify under a different pinned key")
(check-true
(verify-signature artifact
#:public-key pub
#:signature signature)
"original pair still verifies after the wrong-key probe")
(call-with-output-file artifact
(lambda (output) (write-bytes (make-bytes 100000 7) output))
#:exists 'truncate)
(check-exn
exn:fail?
(lambda ()
(verify-signature artifact
#:public-key public-key
#:signature signature
#:expected-sha256 "00")))
(check-true
(verify-signature artifact
#:public-key public-key
#:signature signature
#:expected-sha256 (sha256-file artifact)))

;; ---- keygen options ----------------------------------------------------------
(define-values (_other-private other-public)
(signing-keygen
#:private-key (build-path directory "other.pem")
#:public-key (build-path directory "other-public.pem")))
(check-false
(verify-signature artifact
#:public-key other-public
#:signature signature))

(define-values (enc-priv enc-pub)
(signing-keygen #:private-key (build-path dir "enc.pem")
#:public-key (build-path dir "enc-pub.pem")
#:password "test-password"))
(check-true (signing-key-password-encrypted? enc-priv)
"password keygen wraps the private key")
(define enc-sig (sign-file artifact
#:private-key enc-priv
#:password "test-password"))
(check-true (verify-signature artifact
#:public-key enc-pub
#:signature enc-sig)
"encrypted keypair signs (with password) and verifies")
;; an encrypted PEM's header is ENCRYPTED PRIVATE KEY — i.e. the key
;; material is not sitting on disk in plaintext
(check-true (string-contains? (file->string enc-priv) "ENCRYPTED PRIVATE KEY")
"encrypted PEM header")
(check-false (signing-key-password-encrypted? priv)
"default keygen stays unencrypted")
(define-values (encrypted-private encrypted-public)
(signing-keygen
#:private-key (build-path directory "encrypted.pem")
#:public-key (build-path directory "encrypted-public.pem")
#:password "test-password"))
(check-true (signing-key-password-encrypted? encrypted-private))
(define encrypted-signature
(sign-file artifact
#:private-key encrypted-private
#:password "test-password"))
(check-true
(verify-signature artifact
#:public-key encrypted-public
#:signature encrypted-signature))
(check-true
(string-contains? (file->string encrypted-private)
"ENCRYPTED PRIVATE KEY"))
(check-false (signing-key-password-encrypted? private-key))

;; digests agree between the file and string forms
(check-equal? (sha256-file artifact) (sha256-string (file->bytes artifact))
"sha256-file and sha256-string agree")
(check-equal? (sha256-file artifact)
(sha256-string (file->bytes artifact)))
(check-equal? (string-length (public-key-fingerprint public-key)) 64)

(delete-directory/files dir)
;; Exercise the installed CLI, including the positional artifact syntax.
(define raco (find-executable-path "raco"))
(define cli-keys (build-path directory "cli-keys"))
(define cli-signature (build-path directory "artifact.sig"))
(check-equal?
(system*/exit-code raco "glaze" "updater-keygen"
"--out" (path->string cli-keys))
0)
(check-equal?
(system*/exit-code raco "glaze" "update-sign"
(path->string artifact)
"--key" (path->string (build-path cli-keys "private.pem"))
"--out" (path->string cli-signature))
0)
(check-equal?
(system*/exit-code raco "glaze" "update-verify"
(path->string artifact)
"--pub" (path->string (build-path cli-keys "public.pem"))
"--signature" (path->string cli-signature)
"--sha256" (sha256-file artifact))
0))
(lambda () (delete-directory/files directory)))
Loading
Loading