server: signed release-channel manifest + publishing tooling (#380)
Groundwork for instance auto-updating: server builds publish to the public releases repo with an Ed25519-signed manifest.json feed carrying three channel pointers (edge / beta / stable). The signature covers the exact base64 payload bytes; the payload carries each binary's sha256, so binaries need no separate signature and a replayed stale feed is detectable via generated_at. - release.go: manifest format, sign/verify, baked public key (private seed is in 1Password), and the lchat release keygen|sign|verify|payload subcommands - deploy/release-server.sh: publish (build HEAD -> server-<sha> release -> point edge) and promote <ver> beta|stable (manifest edit, no rebuild) - docs/server-releases.md: channels, publishing flow, trust model
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+689
−0
deploy/release-server.sh+114 −0docs/server-releases.md+107 −0main.go+3 −0release.go+326 −0release_test.go+139 −0
diff --git a/deploy/release-server.sh b/deploy/release-server.sh| new file mode 100755 |
| index 0000000..a5bea62 |
| --- /dev/null |
| +++ b/deploy/release-server.sh |
| @@ -0,0 +1,114 @@ |
| +#!/usr/bin/env sh |
| +# Publish server releases + move the channel feed (edge / beta / stable). |
| +# |
| +# The feed model (docs/server-releases.md): every build gets a GitHub release |
| +# `server-<ver>` on the public releases repo carrying the linux binary; one |
| +# rolling release tagged `server` carries manifest.json β the Ed25519-signed |
| +# feed saying which version each channel points at. Boxes only ever read the |
| +# manifest; promotion is a manifest edit, no rebuild. |
| +# |
| +# ./deploy/release-server.sh publish [notes...] # build HEAD -> release server-<sha> -> point edge at it |
| +# ./deploy/release-server.sh promote <ver> <chan> # point beta/stable at an already-published version |
| +# |
| +# Needs: gh (authed, write access to $LCHAT_RELEASES_REPO), jq, go, and the |
| +# signing key at $LCHAT_RELEASE_KEY_FILE (1Password: "Eleven Messenger server |
| +# release signing key"). |
| +set -eu |
| + |
| +REPO="${LCHAT_RELEASES_REPO:-elevenmessenger/releases}" |
| +KEY="${LCHAT_RELEASE_KEY_FILE:-}" |
| +BIN=lchat-linux-amd64 |
| + |
| +[ -n "$KEY" ] && [ -f "$KEY" ] || { |
| + echo "release-server: set LCHAT_RELEASE_KEY_FILE to the signing key file" >&2; exit 1; } |
| +command -v jq >/dev/null || { echo "release-server: jq is required" >&2; exit 1; } |
| +command -v gh >/dev/null || { echo "release-server: gh is required" >&2; exit 1; } |
| + |
| +TMP="$(mktemp -d)" |
| +trap 'rm -rf "$TMP"' EXIT |
| + |
| +# The signing/manifest code lives in the binary itself, so builder and box |
| +# can't drift on the format. Build a local tool from this checkout. |
| +TOOL="$TMP/lchat-tool" |
| +go build -o "$TOOL" . |
| + |
| +# fetch_payload: current feed payload JSON (verified), or a fresh skeleton. |
| +fetch_payload() { |
| + if gh release download server -R "$REPO" -p manifest.json -O "$TMP/manifest.json" 2>/dev/null; then |
| + "$TOOL" release payload --in "$TMP/manifest.json" |
| + else |
| + echo '{"schema":1,"channels":{}}' |
| + fi |
| +} |
| + |
| +# sign_and_upload: sign $1 (payload json) and clobber the rolling feed asset. |
| +sign_and_upload() { |
| + "$TOOL" release sign --key "$KEY" --in "$1" --out "$TMP/manifest.json" |
| + gh release view server -R "$REPO" >/dev/null 2>&1 || \ |
| + gh release create server -R "$REPO" --title "Server release feed" \ |
| + --notes "Rolling release: manifest.json is the channel feed instances update from. See the messenger repo's docs/server-releases.md." |
| + gh release upload server "$TMP/manifest.json" -R "$REPO" --clobber |
| + "$TOOL" release verify --in "$TMP/manifest.json" |
| +} |
| + |
| +cmd="${1:-}"; shift || true |
| +case "$cmd" in |
| +publish) |
| + # A release must correspond to landed main code β same rule as deploys. |
| + [ -z "$(git status --porcelain)" ] || { echo "release-server: working tree is dirty β release from clean main" >&2; exit 1; } |
| + VER="$(git rev-parse --short HEAD)" |
| + NOTES="${*:-$(git log -1 --pretty=%s)}" |
| + |
| + OUT="$TMP/$BIN" ./deploy/build-linux.sh "$VER" |
| + SHA="$(shasum -a 256 "$TMP/$BIN" | cut -d' ' -f1)" |
| + SIZE="$(wc -c < "$TMP/$BIN" | tr -d ' ')" |
| + echo "$SHA $BIN" > "$TMP/$BIN.sha256" |
| + |
| + if gh release view "server-$VER" -R "$REPO" >/dev/null 2>&1; then |
| + echo "release-server: server-$VER already exists on $REPO (promote it, or land a new commit)" >&2; exit 1 |
| + fi |
| + gh release create "server-$VER" -R "$REPO" --title "Server $VER" --notes "$NOTES" \ |
| + "$TMP/$BIN" "$TMP/$BIN.sha256" |
| + |
| + fetch_payload | jq \ |
| + --arg ver "$VER" --arg sha "$SHA" --argjson size "$SIZE" --arg notes "$NOTES" \ |
| + --arg url "https://github.com/$REPO/releases/download/server-$VER/$BIN" \ |
| + --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ |
| + '.channels.edge = {version:$ver, url:$url, sha256:$sha, size:$size, notes:$notes, published_at:$now}' \ |
| + > "$TMP/payload.json" |
| + sign_and_upload "$TMP/payload.json" |
| + echo "published server-$VER and pointed edge at it" |
| + ;; |
| + |
| +promote) |
| + VER="${1:-}"; CHAN="${2:-}" |
| + case "$CHAN" in edge|beta|stable) ;; *) |
| + echo "usage: release-server.sh promote <ver> edge|beta|stable" >&2; exit 1;; esac |
| + |
| + fetch_payload > "$TMP/payload.json" |
| + # Prefer the entry already on a channel (usually promoting edge -> beta -> |
| + # stable); otherwise rebuild it from the server-<ver> release's own assets. |
| + ENTRY="$(jq --arg ver "$VER" '[.channels[] | select(.version == $ver)][0] // empty' "$TMP/payload.json")" |
| + if [ -z "$ENTRY" ]; then |
| + gh release download "server-$VER" -R "$REPO" -p "$BIN.sha256" -O "$TMP/$BIN.sha256" || { |
| + echo "release-server: no server-$VER release on $REPO" >&2; exit 1; } |
| + SHA="$(cut -d' ' -f1 < "$TMP/$BIN.sha256")" |
| + SIZE="$(gh release view "server-$VER" -R "$REPO" --json assets \ |
| + --jq ".assets[] | select(.name == \"$BIN\") | .size")" |
| + NOTES="$(gh release view "server-$VER" -R "$REPO" --json body --jq .body)" |
| + ENTRY="$(jq -n --arg ver "$VER" --arg sha "$SHA" --argjson size "${SIZE:-0}" --arg notes "$NOTES" \ |
| + --arg url "https://github.com/$REPO/releases/download/server-$VER/$BIN" \ |
| + --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ |
| + '{version:$ver, url:$url, sha256:$sha, size:$size, notes:$notes, published_at:$now}')" |
| + fi |
| + jq --arg chan "$CHAN" --argjson entry "$ENTRY" '.channels[$chan] = $entry' \ |
| + "$TMP/payload.json" > "$TMP/payload2.json" |
| + sign_and_upload "$TMP/payload2.json" |
| + echo "promoted $VER to $CHAN" |
| + ;; |
| + |
| +*) |
| + echo "usage: release-server.sh publish [notes...] | promote <ver> edge|beta|stable" >&2 |
| + exit 1 |
| + ;; |
| +esac |
diff --git a/docs/server-releases.md b/docs/server-releases.md| new file mode 100644 |
| index 0000000..75c4278 |
| --- /dev/null |
| +++ b/docs/server-releases.md |
| @@ -0,0 +1,107 @@ |
| +# π€ Server releases: channels, publishing, and the signed feed |
| + |
| +How server builds get released, how the three channels work, and the trust |
| +model behind instance auto-updating. The publishing tool is |
| +`deploy/release-server.sh`; the format/signing code is `release.go` (shared by |
| +the publisher and the box updater so they can't drift). The box-side updater |
| +that consumes this feed is described in `docs/instance-router.md` (Β§ auto-update, |
| +once it lands). |
| + |
| +## The shape |
| + |
| +The source repo is private, so a box can't read its GitHub releases without a |
| +credential we don't want to hand out. Releases therefore publish to the |
| +**public releases repo** (`elevenmessenger/releases` β the same repo the Mac |
| +app's Sparkle appcast uses, see `ios/release-mac.sh`): |
| + |
| +- **One release per build**: tag `server-<ver>` (ver = the git short sha that |
| + `deploy/build-linux.sh` stamps into the binary as `main.buildVersion`), |
| + carrying `lchat-linux-amd64` and its `.sha256`. |
| +- **One rolling release**: tag `server`, whose single asset `manifest.json` is |
| + the **feed** β re-uploaded with `--clobber` on every publish/promote, so its |
| + URL never changes: |
| + |
| + ``` |
| + https://github.com/elevenmessenger/releases/releases/download/server/manifest.json |
| + ``` |
| + |
| +## Channels |
| + |
| +| channel | meaning | moves when | |
| +|---------|---------|------------| |
| +| `edge` | bleeding edge β tracks `main` | every `publish` | |
| +| `beta` | near-stable, for people who want to test | `promote <ver> beta` | |
| +| `stable` | well tested, should work in all cases β the default channel | `promote <ver> stable` | |
| + |
| +A channel is just a pointer in the manifest. Promotion re-signs the manifest; |
| +no rebuild, no new artifacts. A channel may lag (or be unset early on) β that's |
| +normal. Pointing a channel **back** at an older version is allowed and is how a |
| +bad release gets pulled; updaters follow the channel wherever it points. |
| + |
| +## The manifest |
| + |
| +A signed wrapper β the Ed25519 signature covers the exact base64 payload bytes |
| +(verify first, decode after), so there is no JSON canonicalisation to get wrong: |
| + |
| +```json |
| +{ "payload": "<base64 of the payload JSON>", "sig": "<base64 Ed25519 signature>" } |
| +``` |
| + |
| +Payload: |
| + |
| +```json |
| +{ |
| + "schema": 1, |
| + "generated_at": "2026-07-08T12:00:00Z", |
| + "channels": { |
| + "edge": { "version": "a1b2c3d", |
| + "url": "https://github.com/elevenmessenger/releases/releases/download/server-a1b2c3d/lchat-linux-amd64", |
| + "sha256": "β¦64 hexβ¦", "size": 123456, |
| + "notes": "one-liner", "published_at": "β¦" }, |
| + "beta": { "β¦": "β¦" }, |
| + "stable": { "β¦": "β¦" } |
| + } |
| +} |
| +``` |
| + |
| +## Trust model |
| + |
| +The updater downloads and *executes* native code, so the chain matters: |
| + |
| +- The **public key** is baked into the binary (`releasePubKeyB64` in |
| + `release.go`). The **private key** (an Ed25519 seed) exists only in 1Password |
| + β Private vault, item *"Eleven Messenger server release signing key"* β and |
| + on the operator's machine while signing (`LCHAT_RELEASE_KEY_FILE`), like the |
| + Sparkle EdDSA key. |
| +- The signed payload carries each binary's **sha256**, so the binaries |
| + themselves need no separate signature: a downloaded binary is hashed and |
| + checked against the signed manifest before it is ever staged. Compromising |
| + GitHub (or the download path) without the key yields nothing installable. |
| +- **`generated_at` is stamped at signing time** and consumers persist the last |
| + value they accepted, refusing anything older β so replaying a stale (but |
| + validly signed) manifest can't downgrade a fleet. An intentional pull-back is |
| + a *re-signed* manifest with a newer timestamp, which still applies. |
| + |
| +## Publishing |
| + |
| +```sh |
| +# once per machine: fetch the key from 1Password to a file |
| +export LCHAT_RELEASE_KEY_FILE=~/secrets/lchat-release-key.b64 |
| + |
| +# cut a release from clean main and point edge at it |
| +./deploy/release-server.sh publish "what changed, one line" |
| + |
| +# promote when it's proven |
| +./deploy/release-server.sh promote a1b2c3d beta |
| +./deploy/release-server.sh promote a1b2c3d stable |
| +``` |
| + |
| +Same rule as deploys and TestFlight: **publish from landed `main`**, never a |
| +feature branch (the script refuses a dirty tree). Publishing is manual for now; |
| +a CI job cutting `edge` on every push to `main` is the natural next step. |
| + |
| +`lchat release` subcommands (`keygen` / `sign` / `verify` / `payload`) are the |
| +plumbing the script drives β `verify` checks a manifest exactly the way a box |
| +will, and `keygen` prints a new public key for baking in (only needed if the |
| +key is ever rotated; rotation requires shipping the new public key via the |
| +*old* key's channel first). |
diff --git a/main.go b/main.go| index 9fca73d..531597c 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -153,6 +153,9 @@ func main() { |
| case "init-versions": |
| runInitVersions(os.Args[2:]) |
| return |
| + case "release": |
| + runRelease(os.Args[2:]) |
| + return |
| case "controld": |
| runControld(os.Args[2:]) |
| return |
diff --git a/release.go b/release.go| new file mode 100644 |
| index 0000000..20be70e |
| --- /dev/null |
| +++ b/release.go |
| @@ -0,0 +1,326 @@ |
| +package main |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "flag" |
| + "fmt" |
| + "os" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// Server release channels. |
| +// |
| +// Releases are published to a public GitHub repo (the source repo is private, |
| +// so an unauthenticated box can't read its releases β same reason the Mac app's |
| +// Sparkle appcast lives there, see ios/release-mac.sh). Each build gets a |
| +// `server-<ver>` release carrying the linux binary; a single rolling release |
| +// tagged `server` carries manifest.json, the feed that says which version each |
| +// channel (edge / beta / stable) currently points at. |
| +// |
| +// The manifest is a signed wrapper: {"payload": base64(JSON), "sig": base64}. |
| +// The Ed25519 signature covers the exact base64 payload bytes (verify first, |
| +// decode after), so there's no JSON canonicalisation to get wrong, and the |
| +// payload carries each binary's sha256 β so the binaries need no separate |
| +// signature and a tampered feed can't repoint a channel at an unblessed build. |
| +// generated_at is stamped at signing time; the box updater persists the last |
| +// value it accepted and refuses older manifests, so a replayed stale feed can't |
| +// downgrade the fleet (an intentional channel pull-back re-signs with a newer |
| +// timestamp and still applies). |
| +// |
| +// Publishing/promotion tooling: deploy/release-server.sh (design + trust model: |
| +// docs/server-releases.md). The private key stays with the operator |
| +// (LCHAT_RELEASE_KEY_FILE); only the public key is baked in below. |
| + |
| +// releaseChannels is the fixed channel set, riskiest first. edge tracks main, |
| +// beta is near-stable, stable is the default every new instance starts on. |
| +var releaseChannels = []string{"edge", "beta", "stable"} |
| + |
| +func validChannel(c string) bool { |
| + for _, v := range releaseChannels { |
| + if c == v { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// releasePubKeyB64 is the Ed25519 public key server releases are signed with |
| +// (the private half lives in 1Password, never in the repo). A var so tests can |
| +// swap in their own trust root via setReleasePubKey. |
| +var releasePubKeyB64 = "6pL2QKDCfXqcYGR6Vr5uUZZKrpV6YVwg9rV0Okb8LY8=" |
| + |
| +// releaseManifestURL is where the box updater and instances look for the feed. |
| +// Env-overridable so tests and canaries can point at a local server. |
| +var releaseManifestURL = envOr("LCHAT_UPDATE_MANIFEST_URL", |
| + "https://github.com/elevenmessenger/releases/releases/download/server/manifest.json") |
| + |
| +type releaseEntry struct { |
| + Version string `json:"version"` |
| + URL string `json:"url"` |
| + SHA256 string `json:"sha256"` |
| + Size int64 `json:"size,omitempty"` |
| + Notes string `json:"notes,omitempty"` |
| + PublishedAt string `json:"published_at,omitempty"` |
| +} |
| + |
| +type releaseManifest struct { |
| + Schema int `json:"schema"` |
| + GeneratedAt time.Time `json:"generated_at"` |
| + Channels map[string]releaseEntry `json:"channels"` |
| +} |
| + |
| +// signedManifest is the wire format: the signature covers the exact base64 |
| +// payload string, so verification never depends on re-marshalling JSON. |
| +type signedManifest struct { |
| + Payload string `json:"payload"` |
| + Sig string `json:"sig"` |
| +} |
| + |
| +func releasePubKey() (ed25519.PublicKey, error) { |
| + raw, err := base64.StdEncoding.DecodeString(releasePubKeyB64) |
| + if err != nil || len(raw) != ed25519.PublicKeySize { |
| + return nil, fmt.Errorf("bad release public key (%d bytes, err %v)", len(raw), err) |
| + } |
| + return ed25519.PublicKey(raw), nil |
| +} |
| + |
| +// validateManifest rejects a payload that parsed but can't be acted on β an |
| +// unknown schema, a channel outside the fixed set, or an entry whose version/ |
| +// sha256 the deploy machinery would choke on later. |
| +func validateManifest(m *releaseManifest) error { |
| + if m.Schema != 1 { |
| + return fmt.Errorf("unsupported manifest schema %d", m.Schema) |
| + } |
| + if m.GeneratedAt.IsZero() { |
| + return fmt.Errorf("manifest has no generated_at") |
| + } |
| + for ch, e := range m.Channels { |
| + if !validChannel(ch) { |
| + return fmt.Errorf("unknown channel %q", ch) |
| + } |
| + if !validVersion(e.Version) || e.Version == "dev" { |
| + return fmt.Errorf("channel %s: invalid version %q", ch, e.Version) |
| + } |
| + if !strings.HasPrefix(e.URL, "https://") { |
| + return fmt.Errorf("channel %s: binary URL must be https (%q)", ch, e.URL) |
| + } |
| + if raw, err := hex.DecodeString(e.SHA256); err != nil || len(raw) != sha256.Size { |
| + return fmt.Errorf("channel %s: sha256 must be %d hex chars", ch, sha256.Size*2) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// signReleaseManifest stamps generated_at (now β this is what makes replayed |
| +// old feeds detectable) and wraps the payload in the signed wire format. |
| +func signReleaseManifest(priv ed25519.PrivateKey, m *releaseManifest) ([]byte, error) { |
| + m.Schema = 1 |
| + m.GeneratedAt = time.Now().UTC().Truncate(time.Second) |
| + if err := validateManifest(m); err != nil { |
| + return nil, err |
| + } |
| + payload, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + return nil, err |
| + } |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + sig := ed25519.Sign(priv, []byte(b64)) |
| + return json.MarshalIndent(signedManifest{ |
| + Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(sig), |
| + }, "", " ") |
| +} |
| + |
| +// verifyReleaseManifest checks the signature against pub and only then decodes |
| +// and validates the payload. Everything the caller acts on came from under the |
| +// signature. |
| +func verifyReleaseManifest(pub ed25519.PublicKey, wrapper []byte) (*releaseManifest, error) { |
| + var sm signedManifest |
| + if err := json.Unmarshal(wrapper, &sm); err != nil { |
| + return nil, fmt.Errorf("manifest wrapper: %v", err) |
| + } |
| + sig, err := base64.StdEncoding.DecodeString(sm.Sig) |
| + if err != nil { |
| + return nil, fmt.Errorf("manifest sig: %v", err) |
| + } |
| + if !ed25519.Verify(pub, []byte(sm.Payload), sig) { |
| + return nil, fmt.Errorf("manifest signature verification failed") |
| + } |
| + payload, err := base64.StdEncoding.DecodeString(sm.Payload) |
| + if err != nil { |
| + return nil, fmt.Errorf("manifest payload: %v", err) |
| + } |
| + var m releaseManifest |
| + if err := json.Unmarshal(payload, &m); err != nil { |
| + return nil, fmt.Errorf("manifest payload: %v", err) |
| + } |
| + if err := validateManifest(&m); err != nil { |
| + return nil, err |
| + } |
| + return &m, nil |
| +} |
| + |
| +// readReleaseKey loads the operator's signing key: a file holding the base64 |
| +// Ed25519 seed (written by `lchat release keygen`). |
| +func readReleaseKey(path string) (ed25519.PrivateKey, error) { |
| + raw, err := os.ReadFile(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + seed, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw))) |
| + if err != nil || len(seed) != ed25519.SeedSize { |
| + return nil, fmt.Errorf("%s: expected a base64 %d-byte Ed25519 seed", path, ed25519.SeedSize) |
| + } |
| + return ed25519.NewKeyFromSeed(seed), nil |
| +} |
| + |
| +// runRelease: `lchat release keygen|sign|verify|payload` β the signing side of |
| +// the release feed, kept in the binary so the publisher and the box updater |
| +// share the exact same format code. |
| +func runRelease(args []string) { |
| + if len(args) == 0 { |
| + die("release: subcommand required: keygen | sign | verify | payload") |
| + } |
| + switch args[0] { |
| + case "keygen": |
| + runReleaseKeygen(args[1:]) |
| + case "sign": |
| + runReleaseSign(args[1:]) |
| + case "verify": |
| + runReleaseVerify(args[1:]) |
| + case "payload": |
| + runReleasePayload(args[1:]) |
| + default: |
| + die("release: unknown subcommand %q (keygen | sign | verify | payload)", args[0]) |
| + } |
| +} |
| + |
| +// runReleaseKeygen writes the private seed to --out (0600) and prints the |
| +// public key for baking into release.go. Refuses to overwrite an existing key. |
| +func runReleaseKeygen(args []string) { |
| + fs := flag.NewFlagSet("release keygen", flag.ExitOnError) |
| + out := fs.String("out", "", "file to write the base64 private seed to (required)") |
| + _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| + if *out == "" { |
| + die("release keygen: --out <file> is required") |
| + } |
| + if _, err := os.Stat(*out); err == nil { |
| + die("release keygen: %s already exists β refusing to overwrite a signing key", *out) |
| + } |
| + pub, priv, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + die("release keygen: %v", err) |
| + } |
| + seed := base64.StdEncoding.EncodeToString(priv.Seed()) |
| + if err := os.WriteFile(*out, []byte(seed+"\n"), 0o600); err != nil { |
| + die("release keygen: %v", err) |
| + } |
| + fmt.Printf("private seed written to %s β store it in 1Password, never in the repo\n", *out) |
| + fmt.Printf("public key (bake into release.go releasePubKeyB64):\n%s\n", base64.StdEncoding.EncodeToString(pub)) |
| +} |
| + |
| +// runReleaseSign: payload JSON in, signed wrapper out. Stamps generated_at, so |
| +// re-signing an edited payload always moves the feed's clock forward. |
| +func runReleaseSign(args []string) { |
| + fs := flag.NewFlagSet("release sign", flag.ExitOnError) |
| + keyFile := fs.String("key", envOr("LCHAT_RELEASE_KEY_FILE", ""), "signing key file from `release keygen` (env: LCHAT_RELEASE_KEY_FILE)") |
| + in := fs.String("in", "", "payload JSON to sign (the {channels: ...} document; required)") |
| + out := fs.String("out", "", "where to write the signed manifest (default: stdout)") |
| + _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| + if *keyFile == "" || *in == "" { |
| + die("release sign: --key and --in are required") |
| + } |
| + priv, err := readReleaseKey(*keyFile) |
| + if err != nil { |
| + die("release sign: %v", err) |
| + } |
| + raw, err := os.ReadFile(*in) |
| + if err != nil { |
| + die("release sign: %v", err) |
| + } |
| + var m releaseManifest |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + die("release sign: %s: %v", *in, err) |
| + } |
| + wrapper, err := signReleaseManifest(priv, &m) |
| + if err != nil { |
| + die("release sign: %v", err) |
| + } |
| + if *out == "" { |
| + fmt.Println(string(wrapper)) |
| + return |
| + } |
| + if err := os.WriteFile(*out, append(wrapper, '\n'), 0o644); err != nil { |
| + die("release sign: %v", err) |
| + } |
| + fmt.Printf("signed manifest written to %s (generated_at %s)\n", *out, m.GeneratedAt.Format(time.RFC3339)) |
| +} |
| + |
| +// runReleaseVerify checks a signed manifest against the baked public key (or |
| +// --pubkey) and prints the channel table β the same check the box updater runs. |
| +func runReleaseVerify(args []string) { |
| + fs := flag.NewFlagSet("release verify", flag.ExitOnError) |
| + in := fs.String("in", "", "signed manifest to verify (required)") |
| + pubB64 := fs.String("pubkey", "", "base64 public key to verify against (default: the baked release key)") |
| + _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| + if *in == "" { |
| + die("release verify: --in <file> is required") |
| + } |
| + if *pubB64 != "" { |
| + releasePubKeyB64 = *pubB64 |
| + } |
| + pub, err := releasePubKey() |
| + if err != nil { |
| + die("release verify: %v", err) |
| + } |
| + raw, err := os.ReadFile(*in) |
| + if err != nil { |
| + die("release verify: %v", err) |
| + } |
| + m, err := verifyReleaseManifest(pub, raw) |
| + if err != nil { |
| + die("release verify: %v", err) |
| + } |
| + fmt.Printf("ok β signed %s\n", m.GeneratedAt.Format(time.RFC3339)) |
| + for _, ch := range releaseChannels { |
| + if e, ok := m.Channels[ch]; ok { |
| + fmt.Printf(" %-6s %s %s\n", ch, e.Version, e.URL) |
| + } else { |
| + fmt.Printf(" %-6s (unset)\n", ch) |
| + } |
| + } |
| +} |
| + |
| +// runReleasePayload extracts (and verifies) the payload JSON from a signed |
| +// manifest, so release-server.sh can edit channel pointers with jq and re-sign. |
| +func runReleasePayload(args []string) { |
| + fs := flag.NewFlagSet("release payload", flag.ExitOnError) |
| + in := fs.String("in", "", "signed manifest to read (required)") |
| + _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| + if *in == "" { |
| + die("release payload: --in <file> is required") |
| + } |
| + pub, err := releasePubKey() |
| + if err != nil { |
| + die("release payload: %v", err) |
| + } |
| + raw, err := os.ReadFile(*in) |
| + if err != nil { |
| + die("release payload: %v", err) |
| + } |
| + m, err := verifyReleaseManifest(pub, raw) |
| + if err != nil { |
| + die("release payload: %v", err) |
| + } |
| + out, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + die("release payload: %v", err) |
| + } |
| + fmt.Println(string(out)) |
| +} |
diff --git a/release_test.go b/release_test.go| new file mode 100644 |
| index 0000000..b7d5e3c |
| --- /dev/null |
| +++ b/release_test.go |
| @@ -0,0 +1,139 @@ |
| +package main |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "encoding/base64" |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func testManifest() *releaseManifest { |
| + return &releaseManifest{ |
| + Schema: 1, |
| + GeneratedAt: time.Now().UTC(), |
| + Channels: map[string]releaseEntry{ |
| + "edge": { |
| + Version: "abc1234", |
| + URL: "https://github.com/elevenmessenger/releases/releases/download/server-abc1234/lchat-linux-amd64", |
| + SHA256: strings.Repeat("ab", 32), |
| + Size: 123, |
| + }, |
| + "stable": { |
| + Version: "def5678", |
| + URL: "https://github.com/elevenmessenger/releases/releases/download/server-def5678/lchat-linux-amd64", |
| + SHA256: strings.Repeat("cd", 32), |
| + }, |
| + }, |
| + } |
| +} |
| + |
| +func TestReleaseManifestSignVerifyRoundTrip(t *testing.T) { |
| + pub, priv, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + wrapper, err := signReleaseManifest(priv, testManifest()) |
| + if err != nil { |
| + t.Fatalf("sign: %v", err) |
| + } |
| + m, err := verifyReleaseManifest(pub, wrapper) |
| + if err != nil { |
| + t.Fatalf("verify: %v", err) |
| + } |
| + if m.Channels["edge"].Version != "abc1234" || m.Channels["stable"].Version != "def5678" { |
| + t.Fatalf("channels didn't round-trip: %+v", m.Channels) |
| + } |
| + if m.GeneratedAt.IsZero() { |
| + t.Fatal("generated_at not stamped") |
| + } |
| + // beta is legitimately unset β a channel may lag behind. |
| + if _, ok := m.Channels["beta"]; ok { |
| + t.Fatal("unexpected beta channel") |
| + } |
| +} |
| + |
| +func TestReleaseManifestRejectsTamperAndWrongKey(t *testing.T) { |
| + pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| + otherPub, _, _ := ed25519.GenerateKey(rand.Reader) |
| + wrapper, err := signReleaseManifest(priv, testManifest()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if _, err := verifyReleaseManifest(otherPub, wrapper); err == nil { |
| + t.Fatal("verified with the wrong public key") |
| + } |
| + |
| + // Flip the channel pointer inside the payload; the signature must not survive. |
| + var sm signedManifest |
| + if err := json.Unmarshal(wrapper, &sm); err != nil { |
| + t.Fatal(err) |
| + } |
| + payload, _ := base64.StdEncoding.DecodeString(sm.Payload) |
| + tampered := strings.Replace(string(payload), "abc1234", "eee6666", 1) |
| + sm.Payload = base64.StdEncoding.EncodeToString([]byte(tampered)) |
| + rewrapped, _ := json.Marshal(sm) |
| + if _, err := verifyReleaseManifest(pub, rewrapped); err == nil { |
| + t.Fatal("verified a tampered payload") |
| + } |
| + |
| + if _, err := verifyReleaseManifest(pub, []byte(`{"payload":"","sig":""}`)); err == nil { |
| + t.Fatal("verified an empty wrapper") |
| + } |
| +} |
| + |
| +func TestValidateManifestRejectsBadEntries(t *testing.T) { |
| + cases := map[string]func(*releaseManifest){ |
| + "unknown channel": func(m *releaseManifest) { m.Channels["nightly"] = m.Channels["edge"] }, |
| + "bad version": func(m *releaseManifest) { e := m.Channels["edge"]; e.Version = "../evil"; m.Channels["edge"] = e }, |
| + "dev version": func(m *releaseManifest) { e := m.Channels["edge"]; e.Version = "dev"; m.Channels["edge"] = e }, |
| + "http url": func(m *releaseManifest) { e := m.Channels["edge"]; e.URL = "http://x/lchat"; m.Channels["edge"] = e }, |
| + "short sha256": func(m *releaseManifest) { e := m.Channels["edge"]; e.SHA256 = "abcd"; m.Channels["edge"] = e }, |
| + "non-hex sha256": func(m *releaseManifest) { e := m.Channels["edge"]; e.SHA256 = strings.Repeat("zz", 32); m.Channels["edge"] = e }, |
| + "unknown schema": func(m *releaseManifest) { m.Schema = 2 }, |
| + "zero generatedAt": func(m *releaseManifest) { m.GeneratedAt = time.Time{} }, |
| + } |
| + for name, mutate := range cases { |
| + m := testManifest() |
| + mutate(m) |
| + if err := validateManifest(m); err == nil { |
| + t.Errorf("%s: validateManifest accepted it", name) |
| + } |
| + } |
| + if err := validateManifest(testManifest()); err != nil { |
| + t.Errorf("good manifest rejected: %v", err) |
| + } |
| +} |
| + |
| +func TestReadReleaseKeyRoundTrip(t *testing.T) { |
| + pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| + path := filepath.Join(t.TempDir(), "key.b64") |
| + // keygen writes base64(seed) + newline; mirror that. |
| + if err := os.WriteFile(path, []byte(base64.StdEncoding.EncodeToString(priv.Seed())+"\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := readReleaseKey(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !got.Public().(ed25519.PublicKey).Equal(pub) { |
| + t.Fatal("key didn't round-trip through the seed file") |
| + } |
| + |
| + bad := filepath.Join(t.TempDir(), "bad.b64") |
| + os.WriteFile(bad, []byte("not-a-key\n"), 0o600) |
| + if _, err := readReleaseKey(bad); err == nil { |
| + t.Fatal("accepted a malformed key file") |
| + } |
| +} |
| + |
| +func TestBakedReleasePubKeyParses(t *testing.T) { |
| + if _, err := releasePubKey(); err != nil { |
| + t.Fatalf("baked public key is invalid: %v", err) |
| + } |
| +} |