server: sign releases from LCHAT_RELEASE_KEY in the environment — the key never touches disk (#1142)
The signing seed previously had to exist as a file (even stdin mode parked a tmp copy). Now the seed value itself rides LCHAT_RELEASE_KEY: secret manager -> shell -> the signing tool's memory, nothing written anywhere, and one fetch covers a whole publish && promote chain. File and stdin modes remain as fallbacks; docs updated to the Bitwarden-first flow.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+78
−23
deploy/release-server.sh+24 −10docs/server-releases.md+9 −6release.go+26 −7release_test.go+19 −0
diff --git a/deploy/release-server.sh b/deploy/release-server.sh| index 33b2d2e..e6008e3 100755 |
| --- a/deploy/release-server.sh |
| +++ b/deploy/release-server.sh |
| @@ -22,15 +22,18 @@ |
| # in CLAUDE.local.md, never here). `publish ... && ship` is a full deploy. |
| # |
| # Needs: gh (authed, write access to $LCHAT_RELEASES_REPO), jq, go, and the |
| -# signing key at $LCHAT_RELEASE_KEY_FILE — a file path, or `-` to read the key |
| -# from stdin, so a secret manager pipes it straight in and nothing is left on |
| -# disk or in the environment: |
| +# signing key. Preferred: the seed VALUE in $LCHAT_RELEASE_KEY — it goes |
| +# secret manager -> shell -> the signing tool's memory and never touches disk |
| +# (one fetch covers a whole publish && promote chain): |
| # |
| -# <fetch key from Bitwarden/1Password> \ |
| -# | LCHAT_RELEASE_KEY_FILE=- ./deploy/release-server.sh publish "notes" |
| +# LCHAT_RELEASE_KEY="$(bw get password "Eleven Messenger server release signing key")" \ |
| +# ./deploy/release-server.sh publish "notes" |
| # |
| -# (The key lives in Bitwarden and 1Password as "Eleven Messenger server |
| -# release signing key".) |
| +# Fallbacks: $LCHAT_RELEASE_KEY_FILE as a file path, or `-` to read the key |
| +# from stdin (parked in this run's tmpdir, wiped on exit). |
| +# |
| +# (The key lives in Bitwarden — legacy copy in 1Password — as "Eleven |
| +# Messenger server release signing key".) |
| # |
| # Optional S3 mirror: set LCHAT_RELEASE_MIRROR_BUCKET (needs the aws CLI) and |
| # every feed artifact is also copied to s3://<bucket>/releases/… — GitHub's |
| @@ -60,7 +63,12 @@ trap 'rm -rf "$TMP"' EXIT |
| # and the gh/jq toolchain are required only where the feed is touched |
| # (publish/promote, checked below). |
| case "${1:-}" in publish|promote) |
| - if [ "$KEY" = "-" ]; then |
| + if [ -n "${LCHAT_RELEASE_KEY:-}" ]; then |
| + # The seed itself is in the environment — the preferred flow: it goes |
| + # secret manager -> shell -> the signing tool's memory and never touches |
| + # disk. No key file anywhere below (release sign reads the env directly). |
| + KEY="" |
| + elif [ "$KEY" = "-" ]; then |
| # Key arrives on stdin: park it in this run's private tmpdir (0700 from |
| # mktemp -d, wiped by the trap) so nothing persists past the run. |
| cat > "$TMP/signing.key" |
| @@ -69,7 +77,7 @@ case "${1:-}" in publish|promote) |
| KEY="$TMP/signing.key" |
| else |
| [ -n "$KEY" ] && [ -f "$KEY" ] || { |
| - echo "release-server: set LCHAT_RELEASE_KEY_FILE to the signing key file (or - to read it from stdin)" >&2; exit 1; } |
| + echo "release-server: set LCHAT_RELEASE_KEY to the base64 seed (or LCHAT_RELEASE_KEY_FILE to a key file, or - for stdin)" >&2; exit 1; } |
| fi |
| 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; } |
| @@ -108,8 +116,14 @@ mirror_upload() { |
| } |
| # sign_and_upload: sign $1 (payload json) and clobber the rolling feed asset. |
| +# With LCHAT_RELEASE_KEY in the environment, KEY is empty and the tool reads |
| +# the seed straight from the env — no --key, no file. |
| sign_and_upload() { |
| - "$TOOL" release sign --key "$KEY" --in "$1" --out "$TMP/manifest.json" |
| + if [ -n "$KEY" ]; then |
| + "$TOOL" release sign --key "$KEY" --in "$1" --out "$TMP/manifest.json" |
| + else |
| + "$TOOL" release sign --in "$1" --out "$TMP/manifest.json" |
| + fi |
| gh release view server -R "$REPO" >/dev/null 2>&1 || \ |
| gh release create server -R "$REPO" --title "Server release feed" --latest=false \ |
| --notes "Rolling release: manifest.json is the channel feed instances update from. See the messenger repo's docs/server-releases.md." |
diff --git a/docs/server-releases.md b/docs/server-releases.md| index edfc81f..7af316a 100644 |
| --- a/docs/server-releases.md |
| +++ b/docs/server-releases.md |
| @@ -104,10 +104,12 @@ wrong architecture — `releaseEntry.forArch` in `release.go`). |
| 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. |
| + `release.go`). The **private key** (an Ed25519 seed) exists only in the |
| + password manager — Bitwarden (legacy copy in 1Password), item *"Eleven |
| + Messenger server release signing key"* — and in the signing process's |
| + memory while signing (`LCHAT_RELEASE_KEY`, the seed value in the |
| + environment; it is never written to disk). The older file mode |
| + (`LCHAT_RELEASE_KEY_FILE`) remains as a fallback. |
| - 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 |
| @@ -304,8 +306,9 @@ mirror can only cause a refusal, not an install. |
| ## Publishing |
| ```sh |
| -# once per machine: fetch the key from 1Password to a file |
| -export LCHAT_RELEASE_KEY_FILE=~/secrets/lchat-release-key.b64 |
| +# per session: fetch the seed from Bitwarden into the environment — it never |
| +# touches disk, and one fetch covers a whole publish && promote chain |
| +export LCHAT_RELEASE_KEY="$(bw get password "Eleven Messenger server release signing key")" |
| # cut a release from clean main and point edge at it |
| ./deploy/release-server.sh publish "what changed, one line" |
diff --git a/release.go b/release.go| index b06239c..c1d5e67 100644 |
| --- a/release.go |
| +++ b/release.go |
| @@ -376,9 +376,16 @@ func readReleaseKey(path string) (ed25519.PrivateKey, error) { |
| if err != nil { |
| return nil, err |
| } |
| - seed, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw))) |
| + return parseReleaseSeed(string(raw), path) |
| +} |
| + |
| +// parseReleaseSeed turns a base64 seed — from a key file or the |
| +// LCHAT_RELEASE_KEY env var — into the signing key. src only names the |
| +// origin in the error. |
| +func parseReleaseSeed(b64, src string) (ed25519.PrivateKey, error) { |
| + seed, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) |
| if err != nil || len(seed) != ed25519.SeedSize { |
| - return nil, fmt.Errorf("%s: expected a base64 %d-byte Ed25519 seed", path, ed25519.SeedSize) |
| + return nil, fmt.Errorf("%s: expected a base64 %d-byte Ed25519 seed", src, ed25519.SeedSize) |
| } |
| return ed25519.NewKeyFromSeed(seed), nil |
| } |
| @@ -424,7 +431,7 @@ func runReleaseKeygen(args []string) { |
| 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("private seed written to %s — store it in the password manager, never in the repo\n", *out) |
| fmt.Printf("public key (bake into release.go releasePubKeyB64):\n%s\n", base64.StdEncoding.EncodeToString(pub)) |
| } |
| @@ -432,14 +439,26 @@ func runReleaseKeygen(args []string) { |
| // 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)") |
| + keyFile := fs.String("key", envOr("LCHAT_RELEASE_KEY_FILE", ""), "signing key file from `release keygen` (env: LCHAT_RELEASE_KEY_FILE; or put the seed itself in LCHAT_RELEASE_KEY and skip the 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") |
| + if *in == "" { |
| + die("release sign: --in is required") |
| + } |
| + // The key value itself in the environment beats any file: the seed goes |
| + // secret manager -> shell variable -> this process's memory, and never |
| + // touches disk (not even a tmp copy). The file path stays as the |
| + // fallback for older flows. |
| + var priv ed25519.PrivateKey |
| + var err error |
| + if envKey := strings.TrimSpace(os.Getenv("LCHAT_RELEASE_KEY")); envKey != "" { |
| + priv, err = parseReleaseSeed(envKey, "LCHAT_RELEASE_KEY") |
| + } else if *keyFile != "" { |
| + priv, err = readReleaseKey(*keyFile) |
| + } else { |
| + die("release sign: set LCHAT_RELEASE_KEY (the base64 seed) or pass --key <file>") |
| } |
| - priv, err := readReleaseKey(*keyFile) |
| if err != nil { |
| die("release sign: %v", err) |
| } |
diff --git a/release_test.go b/release_test.go| index bed8e8c..26e9bda 100644 |
| --- a/release_test.go |
| +++ b/release_test.go |
| @@ -179,6 +179,25 @@ func TestReadReleaseKeyRoundTrip(t *testing.T) { |
| } |
| } |
| +// The no-disk path: the seed value itself (as LCHAT_RELEASE_KEY carries it) |
| +// parses straight from a string, tolerating the whitespace a shell fetch |
| +// picks up, and rejects everything that isn't a 32-byte seed. |
| +func TestParseReleaseSeedFromValue(t *testing.T) { |
| + pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| + got, err := parseReleaseSeed(base64.StdEncoding.EncodeToString(priv.Seed())+"\n", "LCHAT_RELEASE_KEY") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !got.Public().(ed25519.PublicKey).Equal(pub) { |
| + t.Fatal("key didn't round-trip through the env value") |
| + } |
| + for _, bad := range []string{"", "not-a-key", base64.StdEncoding.EncodeToString([]byte("short"))} { |
| + if _, err := parseReleaseSeed(bad, "LCHAT_RELEASE_KEY"); err == nil { |
| + t.Fatalf("accepted %q as a seed", bad) |
| + } |
| + } |
| +} |
| + |
| func TestBakedReleasePubKeyParses(t *testing.T) { |
| if _, err := releasePubKey(); err != nil { |
| t.Fatalf("baked public key is invalid: %v", err) |