eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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.
45var releaseChannels = []string{"edge", "beta", "stable"}
46
47func 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.
59var 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.
63var 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.
76var releaseMirrorURL = envOr("LCHAT_RELEASE_MIRROR_URL", "")
77
78// mirrorManifestURL is where a mirror base keeps the feed itself.
79func 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.
87func 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
99type 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.
133type 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
142func (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).
154func (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
164type 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.
172type signedManifest struct {
173 Payload string `json:"payload"`
174 Sig string `json:"sig"`
175}
176
177func 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.
188func 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.
247var 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.
251var semverRe = regexp.MustCompile(`^(\d{1,4})\.\d{1,4}\.\d{1,4}$`)
252
253// semverMajor extracts the major from a MAJOR.MINOR.PATCH string.
254func 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").
268func 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).
285func 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.
314func 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.
325func 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.
346func 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`).
374func 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.
385func 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.
396func 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.
416func 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.
440func 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.
489func 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.
524func 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