eleven / messenger Public

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>
Paul Campbell e7777ddd58ab36370f6ce1ea0015ba9fe045b492 parent 07bf060
4 files changed, +78 −23
  • deploy/release-server.sh +24 −10
  • docs/server-releases.md +9 −6
  • release.go +26 −7
  • release_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)