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