| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "crypto/ed25519" |
| 5 | "crypto/rand" |
| 6 | "crypto/sha256" |
| 7 | "encoding/base64" |
| 8 | "encoding/hex" |
| 9 | "encoding/json" |
| 10 | "flag" |
| 11 | "fmt" |
| 12 | "net/url" |
| 13 | "os" |
| 14 | "regexp" |
| 15 | "strconv" |
| 16 | "strings" |
| 17 | "time" |
| 18 | ) |
| 19 | |
| 20 | // Server release channels. |
| 21 | // |
| 22 | // Releases are published to a public GitHub repo (the source repo is private, |
| 23 | // so an unauthenticated box can't read its releases — same reason the Mac app's |
| 24 | // Sparkle appcast lives there, see ios/release-mac.sh). Each build gets a |
| 25 | // `server-<ver>` release carrying the linux binary; a single rolling release |
| 26 | // tagged `server` carries manifest.json, the feed that says which version each |
| 27 | // channel (edge / beta / stable) currently points at. |
| 28 | // |
| 29 | // The manifest is a signed wrapper: {"payload": base64(JSON), "sig": base64}. |
| 30 | // The Ed25519 signature covers the exact base64 payload bytes (verify first, |
| 31 | // decode after), so there's no JSON canonicalisation to get wrong, and the |
| 32 | // payload carries each binary's sha256 — so the binaries need no separate |
| 33 | // signature and a tampered feed can't repoint a channel at an unblessed build. |
| 34 | // generated_at is stamped at signing time; the box updater persists the last |
| 35 | // value it accepted and refuses older manifests, so a replayed stale feed can't |
| 36 | // downgrade the fleet (an intentional channel pull-back re-signs with a newer |
| 37 | // timestamp and still applies). |
| 38 | // |
| 39 | // Publishing/promotion tooling: deploy/release-server.sh (design + trust model: |
| 40 | // docs/server-releases.md). The private key stays with the operator |
| 41 | // (LCHAT_RELEASE_KEY_FILE); only the public key is baked in below. |
| 42 | |
| 43 | // releaseChannels is the fixed channel set, riskiest first. edge tracks main, |
| 44 | // beta is near-stable, stable is the default every new instance starts on. |
| 45 | var releaseChannels = []string{"edge", "beta", "stable"} |
| 46 | |
| 47 | func validChannel(c string) bool { |
| 48 | for _, v := range releaseChannels { |
| 49 | if c == v { |
| 50 | return true |
| 51 | } |
| 52 | } |
| 53 | return false |
| 54 | } |
| 55 | |
| 56 | // releasePubKeyB64 is the Ed25519 public key server releases are signed with |
| 57 | // (the private half lives in 1Password, never in the repo). A var so tests can |
| 58 | // swap in their own trust root, and `release verify --pubkey` can override it. |
| 59 | var releasePubKeyB64 = "6pL2QKDCfXqcYGR6Vr5uUZZKrpV6YVwg9rV0Okb8LY8=" |
| 60 | |
| 61 | // releaseManifestURL is where the box updater and instances look for the feed. |
| 62 | // Env-overridable so tests and canaries can point at a local server. |
| 63 | var releaseManifestURL = envOr("LCHAT_UPDATE_MANIFEST_URL", |
| 64 | "https://github.com/elevenmessenger/releases/releases/download/server/manifest.json") |
| 65 | |
| 66 | // releaseMirrorURL is the optional second home for the feed — a plain HTTPS |
| 67 | // base (e.g. https://<bucket>.s3.<region>.amazonaws.com/releases) that |
| 68 | // deploy/release-server.sh keeps in sync when LCHAT_RELEASE_MIRROR_BUCKET is |
| 69 | // set at publish time. GitHub's asset CDN has served freshly-clobbered |
| 70 | // manifests stale (and brand-new binaries 404) for minutes after a publish, |
| 71 | // and that feed is the whole fleet's update path — the mirror exists purely |
| 72 | // for availability. It needs no integrity trust of its own: everything |
| 73 | // fetched from it passes the exact same Ed25519 signature and sha256 checks |
| 74 | // as the primary, so a compromised mirror can only cause a refusal, never an |
| 75 | // install. Empty (the default) disables all mirror behaviour. |
| 76 | var releaseMirrorURL = envOr("LCHAT_RELEASE_MIRROR_URL", "") |
| 77 | |
| 78 | // mirrorManifestURL is where a mirror base keeps the feed itself. |
| 79 | func mirrorManifestURL(base string) string { |
| 80 | return strings.TrimRight(base, "/") + "/manifest.json" |
| 81 | } |
| 82 | |
| 83 | // mirrorBinaryURL maps a binary's primary URL onto the mirror, which stores |
| 84 | // each artifact under its last two path segments |
| 85 | // (server-<ver>/lchat-linux-<arch> — the GitHub layout, copied verbatim by |
| 86 | // release-server.sh). "" when the primary URL doesn't have that shape. |
| 87 | func mirrorBinaryURL(base, primary string) string { |
| 88 | u, err := url.Parse(primary) |
| 89 | if err != nil { |
| 90 | return "" |
| 91 | } |
| 92 | segs := strings.Split(strings.Trim(u.Path, "/"), "/") |
| 93 | if len(segs) < 2 || segs[len(segs)-2] == "" || segs[len(segs)-1] == "" { |
| 94 | return "" |
| 95 | } |
| 96 | return strings.TrimRight(base, "/") + "/" + segs[len(segs)-2] + "/" + segs[len(segs)-1] |
| 97 | } |
| 98 | |
| 99 | type releaseEntry struct { |
| 100 | Version string `json:"version"` |
| 101 | // The flat fields are the amd64 binary — kept flat (not per-arch keyed) so |
| 102 | // manifests stay readable by every updater already in the fleet. |
| 103 | URL string `json:"url"` |
| 104 | SHA256 string `json:"sha256"` |
| 105 | Size int64 `json:"size,omitempty"` |
| 106 | // Optional arm64 build of the same version; absent on releases published |
| 107 | // before arm64 support (an arm64 box then skips the version, never |
| 108 | // installs the wrong arch). |
| 109 | ARM64URL string `json:"arm64_url,omitempty"` |
| 110 | ARM64SHA256 string `json:"arm64_sha256,omitempty"` |
| 111 | ARM64Size int64 `json:"arm64_size,omitempty"` |
| 112 | Notes string `json:"notes,omitempty"` |
| 113 | PublishedAt string `json:"published_at,omitempty"` |
| 114 | // Semver is the human version stamped at publish time (deploy/SERVER_SEMVER |
| 115 | // via release-server.sh); Version stays the artifact id (the git short sha). |
| 116 | // Optional and additive — old updaters ignore it — but it's what major |
| 117 | // pinning (update_major) evaluates, so a semver-less entry HOLDS a |
| 118 | // major-pinned instance rather than moving it (fail closed). |
| 119 | Semver string `json:"semver,omitempty"` |
| 120 | // Services are the sibling-service binaries (connect broker, push relay, |
| 121 | // home server) published beside this build. Their shas ride the SIGNED |
| 122 | // manifest — never the loose .sha256 assets — so the box's service |
| 123 | // converge (updaterservices.go) trusts nothing the signature doesn't |
| 124 | // cover. Additive: absent on pre-feature releases, ignored by old |
| 125 | // updaters, and a rebuilt promote entry (release-server.sh's asset-rebuild |
| 126 | // path) carries none — the converge then simply skips. |
| 127 | Services map[string]serviceBuild `json:"services,omitempty"` |
| 128 | } |
| 129 | |
| 130 | // serviceBuild is one sibling-service binary of a release, per arch — the |
| 131 | // same flat-amd64 + optional-arm64 shape as releaseEntry, for the same |
| 132 | // old-updater-compatibility reason. |
| 133 | type serviceBuild struct { |
| 134 | URL string `json:"url"` |
| 135 | SHA256 string `json:"sha256"` |
| 136 | Size int64 `json:"size,omitempty"` |
| 137 | ARM64URL string `json:"arm64_url,omitempty"` |
| 138 | ARM64SHA256 string `json:"arm64_sha256,omitempty"` |
| 139 | ARM64Size int64 `json:"arm64_size,omitempty"` |
| 140 | } |
| 141 | |
| 142 | func (s serviceBuild) forArch(goarch string) (url, sha string, ok bool) { |
| 143 | switch goarch { |
| 144 | case "amd64": |
| 145 | return s.URL, s.SHA256, s.URL != "" |
| 146 | case "arm64": |
| 147 | return s.ARM64URL, s.ARM64SHA256, s.ARM64URL != "" |
| 148 | } |
| 149 | return "", "", false |
| 150 | } |
| 151 | |
| 152 | // forArch resolves the binary for a GOARCH: amd64 is the flat fields, arm64 |
| 153 | // the optional ones (ok=false when a release has no build for that arch). |
| 154 | func (e releaseEntry) forArch(goarch string) (url, sha string, ok bool) { |
| 155 | switch goarch { |
| 156 | case "amd64": |
| 157 | return e.URL, e.SHA256, true |
| 158 | case "arm64": |
| 159 | return e.ARM64URL, e.ARM64SHA256, e.ARM64URL != "" |
| 160 | } |
| 161 | return "", "", false |
| 162 | } |
| 163 | |
| 164 | type releaseManifest struct { |
| 165 | Schema int `json:"schema"` |
| 166 | GeneratedAt time.Time `json:"generated_at"` |
| 167 | Channels map[string]releaseEntry `json:"channels"` |
| 168 | } |
| 169 | |
| 170 | // signedManifest is the wire format: the signature covers the exact base64 |
| 171 | // payload string, so verification never depends on re-marshalling JSON. |
| 172 | type signedManifest struct { |
| 173 | Payload string `json:"payload"` |
| 174 | Sig string `json:"sig"` |
| 175 | } |
| 176 | |
| 177 | func releasePubKey() (ed25519.PublicKey, error) { |
| 178 | raw, err := base64.StdEncoding.DecodeString(releasePubKeyB64) |
| 179 | if err != nil || len(raw) != ed25519.PublicKeySize { |
| 180 | return nil, fmt.Errorf("bad release public key (%d bytes, err %v)", len(raw), err) |
| 181 | } |
| 182 | return ed25519.PublicKey(raw), nil |
| 183 | } |
| 184 | |
| 185 | // validateManifest rejects a payload that parsed but can't be acted on — an |
| 186 | // unknown schema, a channel outside the fixed set, or an entry whose version/ |
| 187 | // sha256 the deploy machinery would choke on later. |
| 188 | func validateManifest(m *releaseManifest) error { |
| 189 | if m.Schema != 1 { |
| 190 | return fmt.Errorf("unsupported manifest schema %d", m.Schema) |
| 191 | } |
| 192 | if m.GeneratedAt.IsZero() { |
| 193 | return fmt.Errorf("manifest has no generated_at") |
| 194 | } |
| 195 | for ch, e := range m.Channels { |
| 196 | if !validChannel(ch) { |
| 197 | return fmt.Errorf("unknown channel %q", ch) |
| 198 | } |
| 199 | if !validVersion(e.Version) || e.Version == "dev" { |
| 200 | return fmt.Errorf("channel %s: invalid version %q", ch, e.Version) |
| 201 | } |
| 202 | if !strings.HasPrefix(e.URL, "https://") && !isLoopbackURL(e.URL) { |
| 203 | return fmt.Errorf("channel %s: binary URL must be https (%q)", ch, e.URL) |
| 204 | } |
| 205 | if raw, err := hex.DecodeString(e.SHA256); err != nil || len(raw) != sha256.Size { |
| 206 | return fmt.Errorf("channel %s: sha256 must be %d hex chars", ch, sha256.Size*2) |
| 207 | } |
| 208 | // The arm64 build is optional, but when present it obeys the same rules. |
| 209 | if e.ARM64URL != "" || e.ARM64SHA256 != "" { |
| 210 | if !strings.HasPrefix(e.ARM64URL, "https://") && !isLoopbackURL(e.ARM64URL) { |
| 211 | return fmt.Errorf("channel %s: arm64 URL must be https (%q)", ch, e.ARM64URL) |
| 212 | } |
| 213 | if raw, err := hex.DecodeString(e.ARM64SHA256); err != nil || len(raw) != sha256.Size { |
| 214 | return fmt.Errorf("channel %s: arm64 sha256 must be %d hex chars", ch, sha256.Size*2) |
| 215 | } |
| 216 | } |
| 217 | if e.Semver != "" { |
| 218 | if _, ok := semverMajor(e.Semver); !ok { |
| 219 | return fmt.Errorf("channel %s: invalid semver %q", ch, e.Semver) |
| 220 | } |
| 221 | } |
| 222 | for name, sb := range e.Services { |
| 223 | if !serviceNameRe.MatchString(name) { |
| 224 | return fmt.Errorf("channel %s: invalid service name %q", ch, name) |
| 225 | } |
| 226 | if !strings.HasPrefix(sb.URL, "https://") && !isLoopbackURL(sb.URL) { |
| 227 | return fmt.Errorf("channel %s: service %s URL must be https (%q)", ch, name, sb.URL) |
| 228 | } |
| 229 | if raw, err := hex.DecodeString(sb.SHA256); err != nil || len(raw) != sha256.Size { |
| 230 | return fmt.Errorf("channel %s: service %s sha256 must be %d hex chars", ch, name, sha256.Size*2) |
| 231 | } |
| 232 | if sb.ARM64URL != "" || sb.ARM64SHA256 != "" { |
| 233 | if !strings.HasPrefix(sb.ARM64URL, "https://") && !isLoopbackURL(sb.ARM64URL) { |
| 234 | return fmt.Errorf("channel %s: service %s arm64 URL must be https (%q)", ch, name, sb.ARM64URL) |
| 235 | } |
| 236 | if raw, err := hex.DecodeString(sb.ARM64SHA256); err != nil || len(raw) != sha256.Size { |
| 237 | return fmt.Errorf("channel %s: service %s arm64 sha256 must be %d hex chars", ch, name, sha256.Size*2) |
| 238 | } |
| 239 | } |
| 240 | } |
| 241 | } |
| 242 | return nil |
| 243 | } |
| 244 | |
| 245 | // serviceNameRe pins service names to the boring lowercase words the box maps |
| 246 | // to /opt/lchat/<name> — anything fancier is a manifest bug, not a feature. |
| 247 | var serviceNameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) |
| 248 | |
| 249 | // semverRe pins the plain MAJOR.MINOR.PATCH shape the release feed stamps — |
| 250 | // no pre-release/build suffixes until something actually needs them. |
| 251 | var semverRe = regexp.MustCompile(`^(\d{1,4})\.\d{1,4}\.\d{1,4}$`) |
| 252 | |
| 253 | // semverMajor extracts the major from a MAJOR.MINOR.PATCH string. |
| 254 | func semverMajor(s string) (int, bool) { |
| 255 | m := semverRe.FindStringSubmatch(s) |
| 256 | if m == nil { |
| 257 | return 0, false |
| 258 | } |
| 259 | n, err := strconv.Atoi(m[1]) |
| 260 | if err != nil { |
| 261 | return 0, false |
| 262 | } |
| 263 | return n, true |
| 264 | } |
| 265 | |
| 266 | // validMajor accepts what update_major may hold: empty (follow everything) or |
| 267 | // a bare major number ("1"). |
| 268 | func validMajor(v string) bool { |
| 269 | if v == "" { |
| 270 | return true |
| 271 | } |
| 272 | if len(v) > 4 { |
| 273 | return false |
| 274 | } |
| 275 | n, err := strconv.Atoi(v) |
| 276 | return err == nil && n >= 0 |
| 277 | } |
| 278 | |
| 279 | // majorHold returns a human reason when an instance pinned to a major must |
| 280 | // not follow a channel entry, "" when it may. Semantics are halt-and-notify: |
| 281 | // nothing above the pinned major installs, and a feed entry with no semver at |
| 282 | // all also holds — the pin is a promise not to cross a boundary, so an |
| 283 | // unlabelled release fails closed rather than sneaking past it. Older majors |
| 284 | // pass (a channel pull-back is never blocked). |
| 285 | func majorHold(prefMajor string, e releaseEntry) string { |
| 286 | if prefMajor == "" { |
| 287 | return "" |
| 288 | } |
| 289 | want, err := strconv.Atoi(prefMajor) |
| 290 | if err != nil { |
| 291 | // The pref readers clear garbled values ("a garbled pin must not freeze |
| 292 | // anything silently") — belt and braces here. |
| 293 | return "" |
| 294 | } |
| 295 | if e.Semver == "" { |
| 296 | 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)", |
| 297 | e.Version, want) |
| 298 | } |
| 299 | got, ok := semverMajor(e.Semver) |
| 300 | if !ok { |
| 301 | // validateManifest rejects malformed semvers, so this is unreachable — |
| 302 | // but an unparseable label must hold, never pass, a pinned instance. |
| 303 | return fmt.Sprintf("%s has unparseable semver %q — held", e.Version, e.Semver) |
| 304 | } |
| 305 | if got > want { |
| 306 | return fmt.Sprintf("v%s (%s) crosses pinned major %d — held (raise or clear the major pin to take it)", |
| 307 | e.Semver, e.Version, want) |
| 308 | } |
| 309 | return "" |
| 310 | } |
| 311 | |
| 312 | // isLoopbackURL allows plain-http binary URLs only on loopback — local feeds |
| 313 | // and tests; anything remote must be https. |
| 314 | func isLoopbackURL(u string) bool { |
| 315 | p, err := url.Parse(u) |
| 316 | if err != nil || p.Scheme != "http" { |
| 317 | return false |
| 318 | } |
| 319 | h := p.Hostname() |
| 320 | return h == "localhost" || h == "127.0.0.1" || h == "::1" |
| 321 | } |
| 322 | |
| 323 | // signReleaseManifest stamps generated_at (now — this is what makes replayed |
| 324 | // old feeds detectable) and wraps the payload in the signed wire format. |
| 325 | func signReleaseManifest(priv ed25519.PrivateKey, m *releaseManifest) ([]byte, error) { |
| 326 | m.Schema = 1 |
| 327 | m.GeneratedAt = time.Now().UTC().Truncate(time.Second) |
| 328 | if err := validateManifest(m); err != nil { |
| 329 | return nil, err |
| 330 | } |
| 331 | payload, err := json.MarshalIndent(m, "", " ") |
| 332 | if err != nil { |
| 333 | return nil, err |
| 334 | } |
| 335 | b64 := base64.StdEncoding.EncodeToString(payload) |
| 336 | sig := ed25519.Sign(priv, []byte(b64)) |
| 337 | return json.MarshalIndent(signedManifest{ |
| 338 | Payload: b64, |
| 339 | Sig: base64.StdEncoding.EncodeToString(sig), |
| 340 | }, "", " ") |
| 341 | } |
| 342 | |
| 343 | // verifyReleaseManifest checks the signature against pub and only then decodes |
| 344 | // and validates the payload. Everything the caller acts on came from under the |
| 345 | // signature. |
| 346 | func verifyReleaseManifest(pub ed25519.PublicKey, wrapper []byte) (*releaseManifest, error) { |
| 347 | var sm signedManifest |
| 348 | if err := json.Unmarshal(wrapper, &sm); err != nil { |
| 349 | return nil, fmt.Errorf("manifest wrapper: %v", err) |
| 350 | } |
| 351 | sig, err := base64.StdEncoding.DecodeString(sm.Sig) |
| 352 | if err != nil { |
| 353 | return nil, fmt.Errorf("manifest sig: %v", err) |
| 354 | } |
| 355 | if !ed25519.Verify(pub, []byte(sm.Payload), sig) { |
| 356 | return nil, fmt.Errorf("manifest signature verification failed") |
| 357 | } |
| 358 | payload, err := base64.StdEncoding.DecodeString(sm.Payload) |
| 359 | if err != nil { |
| 360 | return nil, fmt.Errorf("manifest payload: %v", err) |
| 361 | } |
| 362 | var m releaseManifest |
| 363 | if err := json.Unmarshal(payload, &m); err != nil { |
| 364 | return nil, fmt.Errorf("manifest payload: %v", err) |
| 365 | } |
| 366 | if err := validateManifest(&m); err != nil { |
| 367 | return nil, err |
| 368 | } |
| 369 | return &m, nil |
| 370 | } |
| 371 | |
| 372 | // readReleaseKey loads the operator's signing key: a file holding the base64 |
| 373 | // Ed25519 seed (written by `lchat release keygen`). |
| 374 | func readReleaseKey(path string) (ed25519.PrivateKey, error) { |
| 375 | raw, err := os.ReadFile(path) |
| 376 | if err != nil { |
| 377 | return nil, err |
| 378 | } |
| 379 | return parseReleaseSeed(string(raw), path) |
| 380 | } |
| 381 | |
| 382 | // parseReleaseSeed turns a base64 seed — from a key file or the |
| 383 | // LCHAT_RELEASE_KEY env var — into the signing key. src only names the |
| 384 | // origin in the error. |
| 385 | func parseReleaseSeed(b64, src string) (ed25519.PrivateKey, error) { |
| 386 | seed, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) |
| 387 | if err != nil || len(seed) != ed25519.SeedSize { |
| 388 | return nil, fmt.Errorf("%s: expected a base64 %d-byte Ed25519 seed", src, ed25519.SeedSize) |
| 389 | } |
| 390 | return ed25519.NewKeyFromSeed(seed), nil |
| 391 | } |
| 392 | |
| 393 | // runRelease: `lchat release keygen|sign|verify|payload` — the signing side of |
| 394 | // the release feed, kept in the binary so the publisher and the box updater |
| 395 | // share the exact same format code. |
| 396 | func runRelease(args []string) { |
| 397 | if len(args) == 0 { |
| 398 | die("release: subcommand required: keygen | sign | verify | payload") |
| 399 | } |
| 400 | switch args[0] { |
| 401 | case "keygen": |
| 402 | runReleaseKeygen(args[1:]) |
| 403 | case "sign": |
| 404 | runReleaseSign(args[1:]) |
| 405 | case "verify": |
| 406 | runReleaseVerify(args[1:]) |
| 407 | case "payload": |
| 408 | runReleasePayload(args[1:]) |
| 409 | default: |
| 410 | die("release: unknown subcommand %q (keygen | sign | verify | payload)", args[0]) |
| 411 | } |
| 412 | } |
| 413 | |
| 414 | // runReleaseKeygen writes the private seed to --out (0600) and prints the |
| 415 | // public key for baking into release.go. Refuses to overwrite an existing key. |
| 416 | func runReleaseKeygen(args []string) { |
| 417 | fs := flag.NewFlagSet("release keygen", flag.ExitOnError) |
| 418 | out := fs.String("out", "", "file to write the base64 private seed to (required)") |
| 419 | _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| 420 | if *out == "" { |
| 421 | die("release keygen: --out <file> is required") |
| 422 | } |
| 423 | if _, err := os.Stat(*out); err == nil { |
| 424 | die("release keygen: %s already exists — refusing to overwrite a signing key", *out) |
| 425 | } |
| 426 | pub, priv, err := ed25519.GenerateKey(rand.Reader) |
| 427 | if err != nil { |
| 428 | die("release keygen: %v", err) |
| 429 | } |
| 430 | seed := base64.StdEncoding.EncodeToString(priv.Seed()) |
| 431 | if err := os.WriteFile(*out, []byte(seed+"\n"), 0o600); err != nil { |
| 432 | die("release keygen: %v", err) |
| 433 | } |
| 434 | fmt.Printf("private seed written to %s — store it in the password manager, never in the repo\n", *out) |
| 435 | fmt.Printf("public key (bake into release.go releasePubKeyB64):\n%s\n", base64.StdEncoding.EncodeToString(pub)) |
| 436 | } |
| 437 | |
| 438 | // runReleaseSign: payload JSON in, signed wrapper out. Stamps generated_at, so |
| 439 | // re-signing an edited payload always moves the feed's clock forward. |
| 440 | func runReleaseSign(args []string) { |
| 441 | fs := flag.NewFlagSet("release sign", flag.ExitOnError) |
| 442 | 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)") |
| 443 | in := fs.String("in", "", "payload JSON to sign (the {channels: ...} document; required)") |
| 444 | out := fs.String("out", "", "where to write the signed manifest (default: stdout)") |
| 445 | _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| 446 | if *in == "" { |
| 447 | die("release sign: --in is required") |
| 448 | } |
| 449 | // The key value itself in the environment beats any file: the seed goes |
| 450 | // secret manager -> shell variable -> this process's memory, and never |
| 451 | // touches disk (not even a tmp copy). The file path stays as the |
| 452 | // fallback for older flows. |
| 453 | var priv ed25519.PrivateKey |
| 454 | var err error |
| 455 | if envKey := strings.TrimSpace(os.Getenv("LCHAT_RELEASE_KEY")); envKey != "" { |
| 456 | priv, err = parseReleaseSeed(envKey, "LCHAT_RELEASE_KEY") |
| 457 | } else if *keyFile != "" { |
| 458 | priv, err = readReleaseKey(*keyFile) |
| 459 | } else { |
| 460 | die("release sign: set LCHAT_RELEASE_KEY (the base64 seed) or pass --key <file>") |
| 461 | } |
| 462 | if err != nil { |
| 463 | die("release sign: %v", err) |
| 464 | } |
| 465 | raw, err := os.ReadFile(*in) |
| 466 | if err != nil { |
| 467 | die("release sign: %v", err) |
| 468 | } |
| 469 | var m releaseManifest |
| 470 | if err := json.Unmarshal(raw, &m); err != nil { |
| 471 | die("release sign: %s: %v", *in, err) |
| 472 | } |
| 473 | wrapper, err := signReleaseManifest(priv, &m) |
| 474 | if err != nil { |
| 475 | die("release sign: %v", err) |
| 476 | } |
| 477 | if *out == "" { |
| 478 | fmt.Println(string(wrapper)) |
| 479 | return |
| 480 | } |
| 481 | if err := os.WriteFile(*out, append(wrapper, '\n'), 0o644); err != nil { |
| 482 | die("release sign: %v", err) |
| 483 | } |
| 484 | fmt.Printf("signed manifest written to %s (generated_at %s)\n", *out, m.GeneratedAt.Format(time.RFC3339)) |
| 485 | } |
| 486 | |
| 487 | // runReleaseVerify checks a signed manifest against the baked public key (or |
| 488 | // --pubkey) and prints the channel table — the same check the box updater runs. |
| 489 | func runReleaseVerify(args []string) { |
| 490 | fs := flag.NewFlagSet("release verify", flag.ExitOnError) |
| 491 | in := fs.String("in", "", "signed manifest to verify (required)") |
| 492 | pubB64 := fs.String("pubkey", "", "base64 public key to verify against (default: the baked release key)") |
| 493 | _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| 494 | if *in == "" { |
| 495 | die("release verify: --in <file> is required") |
| 496 | } |
| 497 | if *pubB64 != "" { |
| 498 | releasePubKeyB64 = *pubB64 |
| 499 | } |
| 500 | pub, err := releasePubKey() |
| 501 | if err != nil { |
| 502 | die("release verify: %v", err) |
| 503 | } |
| 504 | raw, err := os.ReadFile(*in) |
| 505 | if err != nil { |
| 506 | die("release verify: %v", err) |
| 507 | } |
| 508 | m, err := verifyReleaseManifest(pub, raw) |
| 509 | if err != nil { |
| 510 | die("release verify: %v", err) |
| 511 | } |
| 512 | fmt.Printf("ok — signed %s\n", m.GeneratedAt.Format(time.RFC3339)) |
| 513 | for _, ch := range releaseChannels { |
| 514 | if e, ok := m.Channels[ch]; ok { |
| 515 | fmt.Printf(" %-6s %s %s\n", ch, e.Version, e.URL) |
| 516 | } else { |
| 517 | fmt.Printf(" %-6s (unset)\n", ch) |
| 518 | } |
| 519 | } |
| 520 | } |
| 521 | |
| 522 | // runReleasePayload extracts (and verifies) the payload JSON from a signed |
| 523 | // manifest, so release-server.sh can edit channel pointers with jq and re-sign. |
| 524 | func runReleasePayload(args []string) { |
| 525 | fs := flag.NewFlagSet("release payload", flag.ExitOnError) |
| 526 | in := fs.String("in", "", "signed manifest to read (required)") |
| 527 | _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| 528 | if *in == "" { |
| 529 | die("release payload: --in <file> is required") |
| 530 | } |
| 531 | pub, err := releasePubKey() |
| 532 | if err != nil { |
| 533 | die("release payload: %v", err) |
| 534 | } |
| 535 | raw, err := os.ReadFile(*in) |
| 536 | if err != nil { |
| 537 | die("release payload: %v", err) |
| 538 | } |
| 539 | m, err := verifyReleaseManifest(pub, raw) |
| 540 | if err != nil { |
| 541 | die("release payload: %v", err) |
| 542 | } |
| 543 | out, err := json.MarshalIndent(m, "", " ") |
| 544 | if err != nil { |
| 545 | die("release payload: %v", err) |
| 546 | } |
| 547 | fmt.Println(string(out)) |
| 548 | } |
| 549 | |