eleven / messenger Public

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>
Paul Campbell 441efb209fe7474fa55a0eac5fed9222bf078253 parent ead1ca3
5 files changed, +689 −0
  • deploy/release-server.sh +114 −0
  • docs/server-releases.md +107 −0
  • main.go +3 −0
  • release.go +326 −0
  • release_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)
+ }
+}