diff --git a/.gitea/workflows/build-server.yml b/.gitea/workflows/build-server.yml index db7c85e..f13ccec 100644 --- a/.gitea/workflows/build-server.yml +++ b/.gitea/workflows/build-server.yml @@ -10,12 +10,18 @@ # releases don't trigger each other's pipelines. # # Required secrets: -# REGISTRY_TOKEN personal access token with read+write package scope — -# the built-in Actions token is NOT accepted by the -# container registry (docker login → unauthorized). -# Create: user Settings → Applications → Generate token. -# REGISTRY_USER optional; defaults to the pushing actor's username. -# The release job needs only the built-in GITHUB_TOKEN. +# REGISTRY_TOKEN personal access token with read+write package scope — +# the built-in Actions token is NOT accepted by the +# container registry (docker login → unauthorized). +# Create: user Settings → Applications → Generate token. +# REGISTRY_USER optional; defaults to the pushing actor's username. +# RELEASE_SIGNING_KEY base64 ed25519 seed that signs SHA256SUMS. Self-updating +# servers verify the signature against the public key baked +# into the binary (selfupdate.DefaultPublicKeyB64) and REFUSE +# unsigned releases, so this job hard-fails without it — +# a release nobody can install is better failed loudly here. +# Mint a pair with: go run ./cmd/release-sign -gen +# The release job otherwise needs only the built-in GITHUB_TOKEN. name: server-release on: @@ -44,6 +50,18 @@ jobs: done (cd ../dist && sha256sum * > SHA256SUMS) + - name: Sign SHA256SUMS + working-directory: server + env: + RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }} + run: | + [ -n "$RELEASE_SIGNING_KEY" ] || { echo "::error::secret RELEASE_SIGNING_KEY is missing — self-updating servers refuse unsigned releases, so publishing one would strand the fleet. Add it under Settings → Actions → Secrets."; exit 1; } + go run ./cmd/release-sign ../dist/SHA256SUMS + # Verify with the key baked into the binary we just built — catches a + # secret that does not match DefaultPublicKeyB64 before it ships. + PUB=$(grep -o 'DefaultPublicKeyB64 = "[^"]*"' internal/selfupdate/selfupdate.go | cut -d'"' -f2) + go run ./cmd/release-sign -verify -pub "$PUB" ../dist/SHA256SUMS + - name: Create release + attach binaries env: TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/server/README.md b/server/README.md index 33f4935..f4719aa 100644 --- a/server/README.md +++ b/server/README.md @@ -106,9 +106,7 @@ ECHOLOT_UDP_LISTEN=203.0.113.10:8442,203.0.113.11:8442,[2001:db8::10]:8442,[2001 Passing `--self-update-api` to `--install-systemd` additionally installs a daily randomized self-update timer (`echolot-server-update.timer`) that restarts the service after a successful -update. Updates are checksum-verified against the release's `SHA256SUMS` (integrity, not -authenticity — signature verification remains TODO before treating the update source as -untrusted). +update. ### Self-update (opt-in, native only) @@ -119,8 +117,15 @@ echolot-server --self-update \ Fetches the newest `server-v*` release asset for this OS/arch and atomically replaces the binary; systemd's `Restart=` brings up the new version. Run it from a systemd timer for -unattended updates. TODO before enabling anywhere untrusted: signature verification of the -downloaded asset. +unattended updates. + +Releases are trusted by signature, not by host: CI signs `SHA256SUMS` with an ed25519 key that +exists only in its secret store (`RELEASE_SIGNING_KEY`), and the updater verifies +`SHA256SUMS.sig` against the public key baked into the binary before believing any checksum — +an unsigned or re-signed release is refused, so a compromised Gitea can withhold updates but not +inject one. Running your own release pipeline? Mint a keypair with +`go run ./cmd/release-sign -gen`, set the secret, and point `ECHOLOT_SELF_UPDATE_PUBKEY` (or +`--self-update-pubkey`) at your public key. ## First contact @@ -144,7 +149,7 @@ go vet ./... CI (`.gitea/workflows/build-server.yml`): tests on every push touching `server/`; tagging `server-v1.2.3` builds + pushes the container image to the Gitea registry and -attaches static linux amd64/arm64 binaries (+ SHA256SUMS) to a release — the same +attaches static linux amd64/arm64 binaries (+ signed SHA256SUMS) to a release — the same artifacts `--self-update` consumes. ## TLS for the admin UI diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index 3350c97..bdd9939 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -113,7 +113,7 @@ func run() error { case actions.MintEnrollToken != "": return mintEnrollToken(cfg, actions.MintEnrollToken) case actions.SelfUpdate: - return selfupdate.Run(cfg.SelfUpdateAPI, Version) + return selfupdate.Run(cfg.SelfUpdateAPI, cfg.SelfUpdatePubKey, Version) } return serve(cfg) } @@ -166,6 +166,29 @@ func serve(cfg *config.Config) error { map[bool]string{true: "container", false: "native"}[cfg.Docker], "state_dir", cfg.StateDir) + // The reserved addresses' proof is only as good as 80/443 actually being free there. + // CheckReserved already keeps OUR listeners away, but a process outside this config pollutes + // them just as silently — the adb-beacon receiver on 0.0.0.0:443 did exactly that. So ask + // the OS, not the config. A hard stop for the same reason CheckReserved is one: the failure + // is invisible, and its first symptom is a measurement calling an intercepted network clean. + if reserved := cfg.ReservedIPs(); len(reserved) > 0 { + occupied, unverifiable := selftest.ReservedWebPortsFree(reserved) + if len(occupied) > 0 { + return fmt.Errorf( + "refusing to start: something outside this server is listening on reserved "+ + "measurement address(es) %s\n"+ + "The interception proof those addresses exist for is void while anything "+ + "answers there.\nFind it with `ss -tlnp | grep -E ':(80|443) '`, stop it, "+ + "or remove the address from ECHOLOT_RESERVED_ADDRS if it is no longer reserved", + strings.Join(occupied, ", ")) + } + for _, u := range unverifiable { + // Not fatal: an address with a typo, or one this host no longer carries, is a + // config problem — refusing to serve over it would take the whole instrument down. + slog.Warn("could not verify a reserved web port is free", "addr", u) + } + } + st, err := store.Open(cfg.StateDir) if err != nil { return fmt.Errorf("state store: %w", err) diff --git a/server/cmd/release-sign/main.go b/server/cmd/release-sign/main.go new file mode 100644 index 0000000..4f8f384 --- /dev/null +++ b/server/cmd/release-sign/main.go @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +// release-sign signs a release manifest (SHA256SUMS) with the project's ed25519 key, producing +// the detached .sig that self-updating servers verify before trusting the checksums. +// +// release-sign -gen mint a keypair (seed on stdout — store it as the CI +// secret RELEASE_SIGNING_KEY; publish the public key) +// release-sign sign; key read from $RELEASE_SIGNING_KEY, writes .sig +// release-sign -verify -pub check against .sig — what the updater will do +// +// Run from CI (build-server.yml); the private key exists only in the Actions secret store, never +// on the release host, which is the property that makes the signature worth having. +package main + +import ( + "flag" + "fmt" + "os" + + "echo-lot.app/server/internal/relsign" +) + +func main() { + gen := flag.Bool("gen", false, "generate a keypair and exit") + verify := flag.Bool("verify", false, "verify against .sig instead of signing") + pub := flag.String("pub", "", "public key (base64) for -verify") + flag.Parse() + + if err := run(*gen, *verify, *pub, flag.Args()); err != nil { + fmt.Fprintln(os.Stderr, "release-sign:", err) + os.Exit(1) + } +} + +func run(gen, verify bool, pub string, args []string) error { + if gen { + pubB64, seedB64, err := relsign.GenerateKey() + if err != nil { + return err + } + fmt.Printf("public key (embed / ECHOLOT_SELF_UPDATE_PUBKEY):\n%s\n\n"+ + "private key (CI secret RELEASE_SIGNING_KEY — this is the only copy):\n%s\n", + pubB64, seedB64) + return nil + } + if len(args) != 1 { + return fmt.Errorf("usage: release-sign [-gen | -verify -pub ] ") + } + file := args[0] + data, err := os.ReadFile(file) + if err != nil { + return err + } + + if verify { + if pub == "" { + return fmt.Errorf("-verify needs -pub") + } + sig, err := os.ReadFile(file + ".sig") + if err != nil { + return err + } + if err := relsign.Verify(pub, data, string(sig)); err != nil { + return err + } + fmt.Printf("%s: signature OK\n", file) + return nil + } + + seed := os.Getenv("RELEASE_SIGNING_KEY") + if seed == "" { + return fmt.Errorf("RELEASE_SIGNING_KEY is not set — refusing to produce an unsigned release") + } + sig, err := relsign.Sign(seed, data) + if err != nil { + return err + } + if err := os.WriteFile(file+".sig", []byte(sig+"\n"), 0o644); err != nil { + return err + } + fmt.Printf("wrote %s.sig\n", file) + return nil +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 4c4d3dd..7204a2e 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -70,6 +70,11 @@ type Config struct { // e.g. https://git.example.net/api/v1/repos/owner/repo SelfUpdateAPI string // ECHOLOT_SELF_UPDATE_API / --self-update-api + // SelfUpdatePubKey overrides the release-signing public key baked into the binary + // (selfupdate.DefaultPublicKeyB64) — for operators running their own release pipeline + // against their own Gitea. Empty = the built-in project key. + SelfUpdatePubKey string // ECHOLOT_SELF_UPDATE_PUBKEY / --self-update-pubkey + // Uploaded-run storage. The default is "anonymous": any enrolled device may upload, // which is what a self-hosted server wants. Operators of shared servers turn it down. UploadsMode string // ECHOLOT_UPLOADS / --uploads (off|anonymous|account) @@ -185,6 +190,7 @@ func Load(args []string) (*Config, *Actions, error) { fs.StringVar(&c.StateDir, "state-dir", envOr("STATE_DIR", defaultStateDir()), "state directory (device store, generated TLS)") fs.StringVar(&c.Name, "name", envOr("NAME", "echolot"), "server profile name") fs.StringVar(&c.SelfUpdateAPI, "self-update-api", envOr("SELF_UPDATE_API", ""), "Gitea repo API base for self-update; empty disables") + fs.StringVar(&c.SelfUpdatePubKey, "self-update-pubkey", envOr("SELF_UPDATE_PUBKEY", ""), "release-signing public key (base64 ed25519) self-update verifies against; empty uses the built-in project key") fs.StringVar(&c.UploadsMode, "uploads", envOr("UPLOADS", "anonymous"), "who may upload measurement runs: off|anonymous|account") fs.Int64Var(&c.UploadMaxBytes, "upload-max-bytes", int64(envInt("UPLOAD_MAX_BYTES", 4<<20)), "largest accepted uploaded run, bytes") fs.IntVar(&c.UploadRetentionDays, "upload-retention-days", envInt("UPLOAD_RETENTION_DAYS", 90), "delete uploaded runs older than this; 0 disables") diff --git a/server/internal/relsign/relsign.go b/server/internal/relsign/relsign.go new file mode 100644 index 0000000..a677ddc --- /dev/null +++ b/server/internal/relsign/relsign.go @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package relsign signs and verifies release manifests (detached ed25519 over SHA256SUMS). +// +// The checksum file alone protects download integrity, not authenticity: SHA256SUMS and the +// binaries come from the same Gitea release, so whoever can alter one can alter both. The +// signature is what separates "the file arrived intact" from "the project published this file" — +// its private key lives in the CI secret store, not on the release host, so a compromised Gitea +// can serve corrupted binaries but cannot make a self-updating server accept them. +// +// Formats, chosen to be reproducible with nothing but a stock library in any language: +// the private key is the base64 of the 32-byte ed25519 seed, the public key the base64 of the +// 32-byte public key, and the signature file the base64 of the 64-byte signature over the exact +// bytes of the signed file. +package relsign + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + "strings" +) + +// GenerateKey mints a fresh signing keypair. +func GenerateKey() (pubB64, seedB64 string, err error) { + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + return "", "", err + } + return base64.StdEncoding.EncodeToString(pub), + base64.StdEncoding.EncodeToString(priv.Seed()), nil +} + +// Sign produces the detached signature (base64) for data. +func Sign(seedB64 string, data []byte) (string, error) { + seed, err := base64.StdEncoding.DecodeString(strings.TrimSpace(seedB64)) + if err != nil { + return "", fmt.Errorf("signing key is not valid base64: %w", err) + } + if len(seed) != ed25519.SeedSize { + return "", fmt.Errorf("signing key must be %d bytes, got %d", ed25519.SeedSize, len(seed)) + } + priv := ed25519.NewKeyFromSeed(seed) + return base64.StdEncoding.EncodeToString(ed25519.Sign(priv, data)), nil +} + +// Verify checks a detached signature. A nil error means the holder of the private key matching +// pubB64 signed exactly these bytes. +func Verify(pubB64 string, data []byte, sigB64 string) error { + pub, err := base64.StdEncoding.DecodeString(strings.TrimSpace(pubB64)) + if err != nil { + return fmt.Errorf("public key is not valid base64: %w", err) + } + if len(pub) != ed25519.PublicKeySize { + return fmt.Errorf("public key must be %d bytes, got %d", ed25519.PublicKeySize, len(pub)) + } + sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sigB64)) + if err != nil { + return fmt.Errorf("signature is not valid base64: %w", err) + } + if !ed25519.Verify(ed25519.PublicKey(pub), data, sig) { + return fmt.Errorf("signature does not verify: the file was not signed by this key, or was altered after signing") + } + return nil +} diff --git a/server/internal/relsign/relsign_test.go b/server/internal/relsign/relsign_test.go new file mode 100644 index 0000000..3deba89 --- /dev/null +++ b/server/internal/relsign/relsign_test.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package relsign + +import ( + "strings" + "testing" +) + +func TestRoundTrip(t *testing.T) { + pub, seed, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + data := []byte("abc123 echolot-server_linux_amd64\n") + sig, err := Sign(seed, data) + if err != nil { + t.Fatal(err) + } + if err := Verify(pub, data, sig); err != nil { + t.Fatalf("a signature this package just made must verify: %v", err) + } +} + +func TestAlteredContentIsRefused(t *testing.T) { + // The attack this exists for: same length, one checksum swapped for another. + pub, seed, _ := GenerateKey() + sig, _ := Sign(seed, []byte("aaa echolot-server_linux_amd64\n")) + if err := Verify(pub, []byte("bbb echolot-server_linux_amd64\n"), sig); err == nil { + t.Fatal("altered content must not verify") + } +} + +func TestWrongKeyIsRefused(t *testing.T) { + // A compromised release host can re-sign with its own key; only ours may pass. + pub1, _, _ := GenerateKey() + _, seed2, _ := GenerateKey() + data := []byte("payload") + sig, _ := Sign(seed2, data) + if err := Verify(pub1, data, sig); err == nil { + t.Fatal("a signature from a different key must not verify") + } +} + +func TestSurroundingWhitespaceIsTolerated(t *testing.T) { + // Keys travel through env vars and files; a trailing newline must not break verification. + pub, seed, _ := GenerateKey() + data := []byte("data") + sig, err := Sign(" "+seed+"\n", data) + if err != nil { + t.Fatal(err) + } + if err := Verify(pub+"\n", data, "\t"+sig+"\n"); err != nil { + t.Fatalf("whitespace around base64 must be tolerated: %v", err) + } +} + +func TestGarbageInputsFailCleanly(t *testing.T) { + pub, seed, _ := GenerateKey() + if _, err := Sign("not base64!!", []byte("x")); err == nil || !strings.Contains(err.Error(), "base64") { + t.Fatalf("bad seed must name the problem, got %v", err) + } + if _, err := Sign("c2hvcnQ=", []byte("x")); err == nil { + t.Fatal("short seed must be refused") + } + if err := Verify("c2hvcnQ=", []byte("x"), "AAAA"); err == nil { + t.Fatal("short public key must be refused") + } + if err := Verify(pub, []byte("x"), "not base64!!"); err == nil { + t.Fatal("bad signature encoding must be refused") + } + _ = seed +} diff --git a/server/internal/selftest/reserved.go b/server/internal/selftest/reserved.go new file mode 100644 index 0000000..9ba79d0 --- /dev/null +++ b/server/internal/selftest/reserved.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package selftest + +import ( + "fmt" + "net" +) + +// ReservedWebPortsFree verifies that nothing on this host — this process or any other — is +// listening on 80/443 of the reserved measurement addresses. +// +// config.CheckReserved keeps *our own* listeners off those ports, but our configuration is not +// the host: the adb-beacon receiver, a separate Python process wildcard-bound to 0.0.0.0:443, +// silently voided the IPv4 interception proof for as long as it ran, and nothing in this server's +// config could have seen it. Asking the OS is the only check that covers processes we did not +// start. +// +// The mechanism is a throwaway bind: if the bind succeeds the port was provably free (closed +// again immediately — nothing is served), and if it fails with EADDRINUSE something is listening +// there. Any other failure (address not assigned to this host, missing privilege) means the +// question could not be answered, which is reported separately rather than pretending either way. +func ReservedWebPortsFree(ips []net.IP) (occupied, unverifiable []string) { + return portsFree(ips, []string{"80", "443"}) +} + +func portsFree(ips []net.IP, ports []string) (occupied, unverifiable []string) { + for _, ip := range ips { + for _, port := range ports { + addr := net.JoinHostPort(ip.String(), port) + ln, err := net.Listen("tcp", addr) + if err == nil { + ln.Close() + continue + } + if isAddrInUse(err) { + occupied = append(occupied, addr) + } else { + unverifiable = append(unverifiable, fmt.Sprintf("%s (%v)", addr, err)) + } + } + } + return occupied, unverifiable +} diff --git a/server/internal/selftest/reserved_test.go b/server/internal/selftest/reserved_test.go new file mode 100644 index 0000000..1f42093 --- /dev/null +++ b/server/internal/selftest/reserved_test.go @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package selftest + +import ( + "net" + "strconv" + "strings" + "testing" +) + +// The real check runs against ports 80/443, which a test cannot bind without privileges; the +// port list is what varies here, the mechanism is identical. + +func TestOccupiedPortIsDetected(t *testing.T) { + // The stray-process scenario: someone else holds the port before we look. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + port := strconv.Itoa(ln.Addr().(*net.TCPAddr).Port) + + occupied, unverifiable := portsFree([]net.IP{net.ParseIP("127.0.0.1")}, []string{port}) + if len(occupied) != 1 || !strings.HasSuffix(occupied[0], ":"+port) { + t.Fatalf("a listening port must be reported occupied, got occupied=%v unverifiable=%v", + occupied, unverifiable) + } +} + +func TestFreePortPassesAndStaysFree(t *testing.T) { + // Find a port that is free by construction, then check it. + probe, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := strconv.Itoa(probe.Addr().(*net.TCPAddr).Port) + probe.Close() + + occupied, unverifiable := portsFree([]net.IP{net.ParseIP("127.0.0.1")}, []string{port}) + if len(occupied) != 0 || len(unverifiable) != 0 { + t.Fatalf("a free port must pass silently, got occupied=%v unverifiable=%v", + occupied, unverifiable) + } + // The check must not keep the port: it proves the state and gets out of the way. + ln, err := net.Listen("tcp", "127.0.0.1:"+port) + if err != nil { + t.Fatalf("the check left the port unusable: %v", err) + } + ln.Close() +} + +func TestUnassignedAddressIsUnverifiableNotOccupied(t *testing.T) { + // 192.0.2.0/24 is TEST-NET-1: never assigned to this host, so the bind fails with something + // other than EADDRINUSE. That is "could not answer", not "occupied" — conflating them would + // refuse startup over a typo in ECHOLOT_RESERVED_ADDRS. + occupied, unverifiable := portsFree([]net.IP{net.ParseIP("192.0.2.1")}, []string{"65001"}) + if len(occupied) != 0 { + t.Fatalf("an unassigned address must not be reported occupied: %v", occupied) + } + if len(unverifiable) != 1 { + t.Fatalf("an unassigned address must be reported unverifiable, got %v", unverifiable) + } +} diff --git a/server/internal/selftest/reserved_unix.go b/server/internal/selftest/reserved_unix.go new file mode 100644 index 0000000..1e10c7b --- /dev/null +++ b/server/internal/selftest/reserved_unix.go @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +//go:build !windows + +package selftest + +import ( + "errors" + "syscall" +) + +func isAddrInUse(err error) bool { return errors.Is(err, syscall.EADDRINUSE) } diff --git a/server/internal/selftest/reserved_windows.go b/server/internal/selftest/reserved_windows.go new file mode 100644 index 0000000..28f418d --- /dev/null +++ b/server/internal/selftest/reserved_windows.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +//go:build windows + +package selftest + +import ( + "errors" + "syscall" +) + +// Winsock reports a taken port as WSAEADDRINUSE (10048), which syscall.EADDRINUSE does not match +// on Windows — and the stdlib syscall package does not export the WSA constant. The server +// deploys on Linux; this exists so the tests tell the truth on a Windows development machine too. +const wsaeaddrinuse = syscall.Errno(10048) + +func isAddrInUse(err error) bool { + return errors.Is(err, wsaeaddrinuse) || errors.Is(err, syscall.EADDRINUSE) +} diff --git a/server/internal/selfupdate/selfupdate.go b/server/internal/selfupdate/selfupdate.go index cb42e60..ed4383e 100644 --- a/server/internal/selfupdate/selfupdate.go +++ b/server/internal/selfupdate/selfupdate.go @@ -9,6 +9,7 @@ package selfupdate import ( "crypto/sha256" + "echo-lot.app/server/internal/relsign" "echo-lot.app/server/internal/system" "encoding/hex" "encoding/json" @@ -22,6 +23,12 @@ import ( "time" ) +// DefaultPublicKeyB64 is the reference deployment's release-signing key (ed25519, base64). The +// matching private key lives only in the CI secret store (RELEASE_SIGNING_KEY) — not in this +// repo, not on the Gitea host, not on any server. Operators running their own release pipeline +// override it with ECHOLOT_SELF_UPDATE_PUBKEY (mint a pair with `release-sign -gen`). +const DefaultPublicKeyB64 = "KcytZd4zNIwqfhTyamtdSrXg8ZqYHGAkVxgn5zR7ZQI=" + type release struct { TagName string `json:"tag_name"` Assets []asset `json:"assets"` @@ -35,10 +42,15 @@ type asset struct { // echolot-server__ newer than currentVersion and atomically // replaces the current executable. The caller (or systemd Restart=) handles // the restart; we never exec ourselves. -func Run(api, currentVersion string) error { +// +// pubKeyB64 is the release-signing public key; empty means [DefaultPublicKeyB64]. +func Run(api, pubKeyB64, currentVersion string) error { if api == "" { return fmt.Errorf("self-update disabled: no --self-update-api / ECHOLOT_SELF_UPDATE_API configured") } + if pubKeyB64 == "" { + pubKeyB64 = DefaultPublicKeyB64 + } client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Get(strings.TrimRight(api, "/") + "/releases/latest") if err != nil { @@ -72,27 +84,39 @@ func Run(api, currentVersion string) error { return fmt.Errorf("release %s has no asset %q", rel.TagName, want) } - // The release must carry SHA256SUMS; refuse to update without it. This - // protects download integrity (truncation, proxy mangling). It is NOT a - // defense against a compromised Gitea — both files come from the same - // place; a detached signature would be needed for that (still TODO). - var sums string - for _, a := range rel.Assets { - if a.Name == "SHA256SUMS" { + // The release must carry SHA256SUMS *and* its detached signature. The checksums alone only + // protect download integrity (truncation, proxy mangling) — they come from the same place as + // the binaries, so whoever can alter one can alter both. The signature is the defense against + // a compromised release host: its private key exists only in the CI secret store, so a valid + // SHA256SUMS.sig means the project's pipeline published exactly these checksums, and the + // checksum then extends that trust to the binary. + fetch := func(name string) ([]byte, error) { + for _, a := range rel.Assets { + if a.Name != name { + continue + } resp, err := client.Get(a.URL) if err != nil { - return fmt.Errorf("fetching SHA256SUMS: %w", err) + return nil, err } - b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - resp.Body.Close() - if err != nil { - return err - } - sums = string(b) + defer resp.Body.Close() + return io.ReadAll(io.LimitReader(resp.Body, 1<<20)) } + return nil, fmt.Errorf("release %s has no asset %q", rel.TagName, name) + } + sums, err := fetch("SHA256SUMS") + if err != nil { + return fmt.Errorf("fetching SHA256SUMS: %w", err) + } + sig, err := fetch("SHA256SUMS.sig") + if err != nil { + return fmt.Errorf("release %s is unsigned — refusing to update (%v)", rel.TagName, err) + } + if err := relsign.Verify(pubKeyB64, sums, string(sig)); err != nil { + return fmt.Errorf("release %s: SHA256SUMS signature rejected — refusing to update: %w", rel.TagName, err) } wantSum := "" - for _, line := range strings.Split(sums, "\n") { + for _, line := range strings.Split(string(sums), "\n") { if fields := strings.Fields(line); len(fields) == 2 && fields[1] == want { wantSum = fields[0] }