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
@@ -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_<GOOS>_<GOARCH> 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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user