Files
echolot/server/internal/relsign/relsign.go
T
mrambossekandClaude Opus 5 a49bef5821 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>
2026-08-02 12:32:02 +02:00

67 lines
2.6 KiB
Go

// 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
}