package main import ( "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "flag" "fmt" "net/url" "os" "regexp" "strconv" "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-` 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, and `release verify --pubkey` can override it. 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") // releaseMirrorURL is the optional second home for the feed — a plain HTTPS // base (e.g. https://.s3..amazonaws.com/releases) that // deploy/release-server.sh keeps in sync when LCHAT_RELEASE_MIRROR_BUCKET is // set at publish time. GitHub's asset CDN has served freshly-clobbered // manifests stale (and brand-new binaries 404) for minutes after a publish, // and that feed is the whole fleet's update path — the mirror exists purely // for availability. It needs no integrity trust of its own: everything // fetched from it passes the exact same Ed25519 signature and sha256 checks // as the primary, so a compromised mirror can only cause a refusal, never an // install. Empty (the default) disables all mirror behaviour. var releaseMirrorURL = envOr("LCHAT_RELEASE_MIRROR_URL", "") // mirrorManifestURL is where a mirror base keeps the feed itself. func mirrorManifestURL(base string) string { return strings.TrimRight(base, "/") + "/manifest.json" } // mirrorBinaryURL maps a binary's primary URL onto the mirror, which stores // each artifact under its last two path segments // (server-/lchat-linux- — the GitHub layout, copied verbatim by // release-server.sh). "" when the primary URL doesn't have that shape. func mirrorBinaryURL(base, primary string) string { u, err := url.Parse(primary) if err != nil { return "" } segs := strings.Split(strings.Trim(u.Path, "/"), "/") if len(segs) < 2 || segs[len(segs)-2] == "" || segs[len(segs)-1] == "" { return "" } return strings.TrimRight(base, "/") + "/" + segs[len(segs)-2] + "/" + segs[len(segs)-1] } type releaseEntry struct { Version string `json:"version"` // The flat fields are the amd64 binary — kept flat (not per-arch keyed) so // manifests stay readable by every updater already in the fleet. URL string `json:"url"` SHA256 string `json:"sha256"` Size int64 `json:"size,omitempty"` // Optional arm64 build of the same version; absent on releases published // before arm64 support (an arm64 box then skips the version, never // installs the wrong arch). ARM64URL string `json:"arm64_url,omitempty"` ARM64SHA256 string `json:"arm64_sha256,omitempty"` ARM64Size int64 `json:"arm64_size,omitempty"` Notes string `json:"notes,omitempty"` PublishedAt string `json:"published_at,omitempty"` // Semver is the human version stamped at publish time (deploy/SERVER_SEMVER // via release-server.sh); Version stays the artifact id (the git short sha). // Optional and additive — old updaters ignore it — but it's what major // pinning (update_major) evaluates, so a semver-less entry HOLDS a // major-pinned instance rather than moving it (fail closed). Semver string `json:"semver,omitempty"` // Services are the sibling-service binaries (connect broker, push relay, // home server) published beside this build. Their shas ride the SIGNED // manifest — never the loose .sha256 assets — so the box's service // converge (updaterservices.go) trusts nothing the signature doesn't // cover. Additive: absent on pre-feature releases, ignored by old // updaters, and a rebuilt promote entry (release-server.sh's asset-rebuild // path) carries none — the converge then simply skips. Services map[string]serviceBuild `json:"services,omitempty"` } // serviceBuild is one sibling-service binary of a release, per arch — the // same flat-amd64 + optional-arm64 shape as releaseEntry, for the same // old-updater-compatibility reason. type serviceBuild struct { URL string `json:"url"` SHA256 string `json:"sha256"` Size int64 `json:"size,omitempty"` ARM64URL string `json:"arm64_url,omitempty"` ARM64SHA256 string `json:"arm64_sha256,omitempty"` ARM64Size int64 `json:"arm64_size,omitempty"` } func (s serviceBuild) forArch(goarch string) (url, sha string, ok bool) { switch goarch { case "amd64": return s.URL, s.SHA256, s.URL != "" case "arm64": return s.ARM64URL, s.ARM64SHA256, s.ARM64URL != "" } return "", "", false } // forArch resolves the binary for a GOARCH: amd64 is the flat fields, arm64 // the optional ones (ok=false when a release has no build for that arch). func (e releaseEntry) forArch(goarch string) (url, sha string, ok bool) { switch goarch { case "amd64": return e.URL, e.SHA256, true case "arm64": return e.ARM64URL, e.ARM64SHA256, e.ARM64URL != "" } return "", "", false } 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://") && !isLoopbackURL(e.URL) { 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) } // The arm64 build is optional, but when present it obeys the same rules. if e.ARM64URL != "" || e.ARM64SHA256 != "" { if !strings.HasPrefix(e.ARM64URL, "https://") && !isLoopbackURL(e.ARM64URL) { return fmt.Errorf("channel %s: arm64 URL must be https (%q)", ch, e.ARM64URL) } if raw, err := hex.DecodeString(e.ARM64SHA256); err != nil || len(raw) != sha256.Size { return fmt.Errorf("channel %s: arm64 sha256 must be %d hex chars", ch, sha256.Size*2) } } if e.Semver != "" { if _, ok := semverMajor(e.Semver); !ok { return fmt.Errorf("channel %s: invalid semver %q", ch, e.Semver) } } for name, sb := range e.Services { if !serviceNameRe.MatchString(name) { return fmt.Errorf("channel %s: invalid service name %q", ch, name) } if !strings.HasPrefix(sb.URL, "https://") && !isLoopbackURL(sb.URL) { return fmt.Errorf("channel %s: service %s URL must be https (%q)", ch, name, sb.URL) } if raw, err := hex.DecodeString(sb.SHA256); err != nil || len(raw) != sha256.Size { return fmt.Errorf("channel %s: service %s sha256 must be %d hex chars", ch, name, sha256.Size*2) } if sb.ARM64URL != "" || sb.ARM64SHA256 != "" { if !strings.HasPrefix(sb.ARM64URL, "https://") && !isLoopbackURL(sb.ARM64URL) { return fmt.Errorf("channel %s: service %s arm64 URL must be https (%q)", ch, name, sb.ARM64URL) } if raw, err := hex.DecodeString(sb.ARM64SHA256); err != nil || len(raw) != sha256.Size { return fmt.Errorf("channel %s: service %s arm64 sha256 must be %d hex chars", ch, name, sha256.Size*2) } } } } return nil } // serviceNameRe pins service names to the boring lowercase words the box maps // to /opt/lchat/ — anything fancier is a manifest bug, not a feature. var serviceNameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) // semverRe pins the plain MAJOR.MINOR.PATCH shape the release feed stamps — // no pre-release/build suffixes until something actually needs them. var semverRe = regexp.MustCompile(`^(\d{1,4})\.\d{1,4}\.\d{1,4}$`) // semverMajor extracts the major from a MAJOR.MINOR.PATCH string. func semverMajor(s string) (int, bool) { m := semverRe.FindStringSubmatch(s) if m == nil { return 0, false } n, err := strconv.Atoi(m[1]) if err != nil { return 0, false } return n, true } // validMajor accepts what update_major may hold: empty (follow everything) or // a bare major number ("1"). func validMajor(v string) bool { if v == "" { return true } if len(v) > 4 { return false } n, err := strconv.Atoi(v) return err == nil && n >= 0 } // majorHold returns a human reason when an instance pinned to a major must // not follow a channel entry, "" when it may. Semantics are halt-and-notify: // nothing above the pinned major installs, and a feed entry with no semver at // all also holds — the pin is a promise not to cross a boundary, so an // unlabelled release fails closed rather than sneaking past it. Older majors // pass (a channel pull-back is never blocked). func majorHold(prefMajor string, e releaseEntry) string { if prefMajor == "" { return "" } want, err := strconv.Atoi(prefMajor) if err != nil { // The pref readers clear garbled values ("a garbled pin must not freeze // anything silently") — belt and braces here. return "" } if e.Semver == "" { return fmt.Sprintf("%s carries no semver but this instance is pinned to major %d — held (publish with a semver, or clear the major pin)", e.Version, want) } got, ok := semverMajor(e.Semver) if !ok { // validateManifest rejects malformed semvers, so this is unreachable — // but an unparseable label must hold, never pass, a pinned instance. return fmt.Sprintf("%s has unparseable semver %q — held", e.Version, e.Semver) } if got > want { return fmt.Sprintf("v%s (%s) crosses pinned major %d — held (raise or clear the major pin to take it)", e.Semver, e.Version, want) } return "" } // isLoopbackURL allows plain-http binary URLs only on loopback — local feeds // and tests; anything remote must be https. func isLoopbackURL(u string) bool { p, err := url.Parse(u) if err != nil || p.Scheme != "http" { return false } h := p.Hostname() return h == "localhost" || h == "127.0.0.1" || h == "::1" } // 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 } 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", src, 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 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 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)) } // 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; 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 *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 ") } 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 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 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)) }