server: refuse unsigned releases and polluted reserved addresses
Self-update now verifies SHA256SUMS.sig (ed25519, relsign package) against a public key baked into the binary; the private key exists only in the CI secret store, so a compromised release host can withhold updates but not inject one. CI signs on every server-v* tag and hard-fails without the secret. Operators with their own pipeline override the key via ECHOLOT_SELF_UPDATE_PUBKEY (mint a pair with release-sign -gen). Startup also now proves 80/443 are actually free on the reserved measurement addresses by asking the OS (throwaway bind), not the config - CheckReserved could never see a stray process, and the adb-beacon receiver on 0.0.0.0:443 was exactly that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
20cfecf566
commit
a49bef5821
@@ -15,7 +15,13 @@
|
|||||||
# container registry (docker login → unauthorized).
|
# container registry (docker login → unauthorized).
|
||||||
# Create: user Settings → Applications → Generate token.
|
# Create: user Settings → Applications → Generate token.
|
||||||
# REGISTRY_USER optional; defaults to the pushing actor's username.
|
# REGISTRY_USER optional; defaults to the pushing actor's username.
|
||||||
# The release job needs only the built-in GITHUB_TOKEN.
|
# 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
|
name: server-release
|
||||||
on:
|
on:
|
||||||
@@ -44,6 +50,18 @@ jobs:
|
|||||||
done
|
done
|
||||||
(cd ../dist && sha256sum * > SHA256SUMS)
|
(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
|
- name: Create release + attach binaries
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
+11
-6
@@ -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
|
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
|
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
|
update.
|
||||||
authenticity — signature verification remains TODO before treating the update source as
|
|
||||||
untrusted).
|
|
||||||
|
|
||||||
### Self-update (opt-in, native only)
|
### 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
|
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
|
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
|
unattended updates.
|
||||||
downloaded asset.
|
|
||||||
|
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
|
## First contact
|
||||||
|
|
||||||
@@ -144,7 +149,7 @@ go vet ./...
|
|||||||
|
|
||||||
CI (`.gitea/workflows/build-server.yml`): tests on every push touching `server/`;
|
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
|
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.
|
artifacts `--self-update` consumes.
|
||||||
|
|
||||||
## TLS for the admin UI
|
## TLS for the admin UI
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func run() error {
|
|||||||
case actions.MintEnrollToken != "":
|
case actions.MintEnrollToken != "":
|
||||||
return mintEnrollToken(cfg, actions.MintEnrollToken)
|
return mintEnrollToken(cfg, actions.MintEnrollToken)
|
||||||
case actions.SelfUpdate:
|
case actions.SelfUpdate:
|
||||||
return selfupdate.Run(cfg.SelfUpdateAPI, Version)
|
return selfupdate.Run(cfg.SelfUpdateAPI, cfg.SelfUpdatePubKey, Version)
|
||||||
}
|
}
|
||||||
return serve(cfg)
|
return serve(cfg)
|
||||||
}
|
}
|
||||||
@@ -166,6 +166,29 @@ func serve(cfg *config.Config) error {
|
|||||||
map[bool]string{true: "container", false: "native"}[cfg.Docker],
|
map[bool]string{true: "container", false: "native"}[cfg.Docker],
|
||||||
"state_dir", cfg.StateDir)
|
"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)
|
st, err := store.Open(cfg.StateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("state store: %w", err)
|
return fmt.Errorf("state store: %w", err)
|
||||||
|
|||||||
@@ -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 <file>.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 <file> sign; key read from $RELEASE_SIGNING_KEY, writes <file>.sig
|
||||||
|
// release-sign -verify -pub <b64> <f> check <f> against <f>.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 <file> against <file>.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 <b64>] <file>")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -70,6 +70,11 @@ type Config struct {
|
|||||||
// e.g. https://git.example.net/api/v1/repos/owner/repo
|
// e.g. https://git.example.net/api/v1/repos/owner/repo
|
||||||
SelfUpdateAPI string // ECHOLOT_SELF_UPDATE_API / --self-update-api
|
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,
|
// 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.
|
// which is what a self-hosted server wants. Operators of shared servers turn it down.
|
||||||
UploadsMode string // ECHOLOT_UPLOADS / --uploads (off|anonymous|account)
|
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.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.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.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.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.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")
|
fs.IntVar(&c.UploadRetentionDays, "upload-retention-days", envInt("UPLOAD_RETENTION_DAYS", 90), "delete uploaded runs older than this; 0 disables")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ package selfupdate
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"echo-lot.app/server/internal/relsign"
|
||||||
"echo-lot.app/server/internal/system"
|
"echo-lot.app/server/internal/system"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -22,6 +23,12 @@ import (
|
|||||||
"time"
|
"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 {
|
type release struct {
|
||||||
TagName string `json:"tag_name"`
|
TagName string `json:"tag_name"`
|
||||||
Assets []asset `json:"assets"`
|
Assets []asset `json:"assets"`
|
||||||
@@ -35,10 +42,15 @@ type asset struct {
|
|||||||
// echolot-server_<GOOS>_<GOARCH> newer than currentVersion and atomically
|
// echolot-server_<GOOS>_<GOARCH> newer than currentVersion and atomically
|
||||||
// replaces the current executable. The caller (or systemd Restart=) handles
|
// replaces the current executable. The caller (or systemd Restart=) handles
|
||||||
// the restart; we never exec ourselves.
|
// 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 == "" {
|
if api == "" {
|
||||||
return fmt.Errorf("self-update disabled: no --self-update-api / ECHOLOT_SELF_UPDATE_API configured")
|
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}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
resp, err := client.Get(strings.TrimRight(api, "/") + "/releases/latest")
|
resp, err := client.Get(strings.TrimRight(api, "/") + "/releases/latest")
|
||||||
if err != nil {
|
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)
|
return fmt.Errorf("release %s has no asset %q", rel.TagName, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The release must carry SHA256SUMS; refuse to update without it. This
|
// The release must carry SHA256SUMS *and* its detached signature. The checksums alone only
|
||||||
// protects download integrity (truncation, proxy mangling). It is NOT a
|
// protect download integrity (truncation, proxy mangling) — they come from the same place as
|
||||||
// defense against a compromised Gitea — both files come from the same
|
// the binaries, so whoever can alter one can alter both. The signature is the defense against
|
||||||
// place; a detached signature would be needed for that (still TODO).
|
// a compromised release host: its private key exists only in the CI secret store, so a valid
|
||||||
var sums string
|
// 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 {
|
for _, a := range rel.Assets {
|
||||||
if a.Name == "SHA256SUMS" {
|
if a.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
resp, err := client.Get(a.URL)
|
resp, err := client.Get(a.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("fetching SHA256SUMS: %w", err)
|
return fmt.Errorf("fetching SHA256SUMS: %w", err)
|
||||||
}
|
}
|
||||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
sig, err := fetch("SHA256SUMS.sig")
|
||||||
resp.Body.Close()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("release %s is unsigned — refusing to update (%v)", rel.TagName, err)
|
||||||
}
|
|
||||||
sums = string(b)
|
|
||||||
}
|
}
|
||||||
|
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 := ""
|
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 {
|
if fields := strings.Fields(line); len(fields) == 2 && fields[1] == want {
|
||||||
wantSum = fields[0]
|
wantSum = fields[0]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user