fix(security): UPnP opt-in, bounded SSE reader, signed self-update
Phase 2 security audit follow-up. Three independent hardenings against the unauthenticated daemon surface, the long-lived agent SSE stream and the self-update channel. UPnP is now opt-in. The stream port + /hls endpoints have no auth, so publishing them on the WAN via the gateway was a default that exposed active downloads to anyone scanning the operator's external IP. New config downloads.enable_upnp (default false) gates the mapping; LAN and Tailscale clients continue to work unchanged. A startup log makes the new default visible. The agent SSE reader now uses a bounded bufio.Scanner instead of an unbounded ReadString. A hostile or buggy server can no longer grow daemon memory by streaming a single line forever or by emitting unbounded data: continuation lines — both are capped at 256 KiB and 1 MiB respectively, and an error is surfaced so SignalLoop reconnects. Self-update now verifies an ed25519 signature over checksums.txt when the binary was built with a release public key embedded (injected via goreleaser ldflags from RELEASE_SIGNING_PUBKEY). The companion scripts/sign-checksums runs in the release workflow when both the public-key variable and the private-key secret are present, uploading checksums.txt.sig next to the existing checksums file. Builds without the embedded key continue to update with SHA256-only verification; a --allow-unsigned flag is provided so users on a signed build can still install pre-signing releases or recover from an accidental unsigned release. A new scripts/gen-release-key helper documents the one-time keypair generation procedure required before flipping signing on.
This commit is contained in:
parent
c148cb8ce7
commit
433e375def
17 changed files with 551 additions and 32 deletions
|
|
@ -2,6 +2,7 @@ package upgrade
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
|
@ -88,7 +89,23 @@ func download(ctx context.Context, version string) (string, error) {
|
|||
}
|
||||
|
||||
// verifyChecksum downloads checksums.txt and verifies the archive's SHA256.
|
||||
// When a release public key is embedded at build time (releasePubKeyBase64),
|
||||
// the function also verifies an ed25519 signature over checksums.txt before
|
||||
// trusting any hash inside it — this turns the checksum file from a passive
|
||||
// integrity check into an authenticated artifact that a maintainer or CI key
|
||||
// compromise cannot trivially forge.
|
||||
func verifyChecksum(ctx context.Context, version, archivePath string) error {
|
||||
return verifyChecksumWithOptions(ctx, version, archivePath, true)
|
||||
}
|
||||
|
||||
// verifyChecksumOnly skips the ed25519 signature step. Used by Upgrader
|
||||
// when --allow-unsigned is set and the release is known to predate signing
|
||||
// (or when a release accidentally shipped without a .sig file).
|
||||
func verifyChecksumOnly(ctx context.Context, version, archivePath string) error {
|
||||
return verifyChecksumWithOptions(ctx, version, archivePath, false)
|
||||
}
|
||||
|
||||
func verifyChecksumWithOptions(ctx context.Context, version, archivePath string, verifySignature bool) error {
|
||||
// Download checksums.txt
|
||||
url := releaseURL(version, "checksums.txt")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
|
|
@ -107,11 +124,28 @@ func verifyChecksum(ctx context.Context, version, archivePath string) error {
|
|||
return fmt.Errorf("fetch checksums: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read the entire checksums.txt content first so we can both parse and
|
||||
// verify the signature over the same bytes.
|
||||
checksumsContent, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read checksums: %w", err)
|
||||
}
|
||||
|
||||
// Verify ed25519 signature over checksums.txt before trusting its
|
||||
// contents. Skipped silently when no key is embedded (handled by the
|
||||
// caller via SignatureVerificationConfigured) or when the caller
|
||||
// explicitly opts out via --allow-unsigned.
|
||||
if verifySignature {
|
||||
if err := verifyChecksumsSignature(ctx, version, checksumsContent); err != nil {
|
||||
return fmt.Errorf("verify signature: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse checksums.txt — format: "<sha256> <filename>"
|
||||
expectedName := archiveName(version)
|
||||
var expectedHash string
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner := bufio.NewScanner(bytes.NewReader(checksumsContent))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parts := strings.Fields(line)
|
||||
|
|
|
|||
112
internal/upgrade/signature.go
Normal file
112
internal/upgrade/signature.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package upgrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// releasePubKeyBase64 is the base64-encoded ed25519 public key used to verify
|
||||
// `checksums.txt.sig` against `checksums.txt` during self-update.
|
||||
//
|
||||
// It is overridable at link time via ldflags so the same source compiles for
|
||||
// users who do not yet have a release-signing keypair in their CI:
|
||||
//
|
||||
// -X github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64=<base64-pubkey>
|
||||
//
|
||||
// When the variable is empty, signature verification is skipped and a warning
|
||||
// is logged — checksum-only verification remains in force. This is the
|
||||
// transitional default until the keypair is provisioned; flip to a non-empty
|
||||
// value (and enable the corresponding CI signing step) to make signature
|
||||
// verification mandatory.
|
||||
var releasePubKeyBase64 = ""
|
||||
|
||||
// ErrMissingSignature indicates the release does not ship a `.sig` file even
|
||||
// though signature verification is required by an embedded public key.
|
||||
var ErrMissingSignature = errors.New("release signature file is missing")
|
||||
|
||||
// verifyChecksumsSignature downloads `checksums.txt.sig` (raw 64-byte ed25519
|
||||
// signature over the checksums.txt content) and verifies it with the embedded
|
||||
// public key. Returns nil if verification succeeds or if no public key has
|
||||
// been embedded yet (caller is expected to surface a warning in that case).
|
||||
func verifyChecksumsSignature(ctx context.Context, version string, checksumsContent []byte) error {
|
||||
pubKey, err := loadReleasePubKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load release pubkey: %w", err)
|
||||
}
|
||||
if pubKey == nil {
|
||||
// Signature verification not configured; caller decides what to do.
|
||||
return nil
|
||||
}
|
||||
|
||||
url := releaseURL(version, "checksums.txt.sig")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "unarr-updater")
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch signature: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return ErrMissingSignature
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("fetch signature: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Signature file is base64(signature)\n — small and bounded.
|
||||
rawSig, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read signature: %w", err)
|
||||
}
|
||||
sig, err := decodeSignature(rawSig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode signature: %w", err)
|
||||
}
|
||||
if len(sig) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("signature size %d, expected %d", len(sig), ed25519.SignatureSize)
|
||||
}
|
||||
if !ed25519.Verify(pubKey, checksumsContent, sig) {
|
||||
return errors.New("ed25519 signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SignatureVerificationConfigured reports whether the build has a release
|
||||
// public key embedded. The CLI surfaces this so users running a non-signed
|
||||
// build get a clear warning rather than silent trust.
|
||||
func SignatureVerificationConfigured() bool {
|
||||
pubKey, err := loadReleasePubKey()
|
||||
return err == nil && pubKey != nil
|
||||
}
|
||||
|
||||
func loadReleasePubKey() (ed25519.PublicKey, error) {
|
||||
v := strings.TrimSpace(releasePubKeyBase64)
|
||||
if v == "" {
|
||||
return nil, nil
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 decode: %w", err)
|
||||
}
|
||||
if len(raw) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("pubkey size %d, expected %d", len(raw), ed25519.PublicKeySize)
|
||||
}
|
||||
return ed25519.PublicKey(raw), nil
|
||||
}
|
||||
|
||||
// decodeSignature parses the base64-encoded signature emitted by
|
||||
// scripts/sign-checksums (always base64 + trailing newline). A single
|
||||
// expected format keeps the surface area minimal — a stricter parser is
|
||||
// less likely to accept a hostile mirror's coincidentally-sized payload.
|
||||
func decodeSignature(raw []byte) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw)))
|
||||
}
|
||||
134
internal/upgrade/signature_test.go
Normal file
134
internal/upgrade/signature_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package upgrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withReleasePubKey temporarily swaps the embedded release public key and
|
||||
// restores the previous value on test exit.
|
||||
func withReleasePubKey(t *testing.T, encoded string) {
|
||||
t.Helper()
|
||||
prev := releasePubKeyBase64
|
||||
releasePubKeyBase64 = encoded
|
||||
t.Cleanup(func() { releasePubKeyBase64 = prev })
|
||||
}
|
||||
|
||||
func TestSignatureVerificationDisabledByDefault(t *testing.T) {
|
||||
withReleasePubKey(t, "")
|
||||
if SignatureVerificationConfigured() {
|
||||
t.Fatal("expected SignatureVerificationConfigured() to be false when pubkey is empty")
|
||||
}
|
||||
// verifyChecksumsSignature should be a no-op when no key is embedded.
|
||||
if err := verifyChecksumsSignature(context.Background(), "0.0.0", []byte("anything")); err != nil {
|
||||
t.Fatalf("expected nil when pubkey is empty, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureRejectsMalformedPubKey(t *testing.T) {
|
||||
withReleasePubKey(t, "not-base64!!")
|
||||
if _, err := loadReleasePubKey(); err == nil {
|
||||
t.Fatal("expected error from malformed base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureRejectsWrongSizePubKey(t *testing.T) {
|
||||
withReleasePubKey(t, base64.StdEncoding.EncodeToString([]byte("too-short")))
|
||||
if _, err := loadReleasePubKey(); err == nil {
|
||||
t.Fatal("expected error from wrong-size pubkey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureVerifiesGoodSignature(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate keypair: %v", err)
|
||||
}
|
||||
withReleasePubKey(t, base64.StdEncoding.EncodeToString(pub))
|
||||
|
||||
checksumsBody := []byte("deadbeef unarr_0.0.0_linux_amd64.tar.gz\n")
|
||||
signature := ed25519.Sign(priv, checksumsBody)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "checksums.txt.sig") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, base64.StdEncoding.EncodeToString(signature))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
prevHost := githubReleaseHost
|
||||
githubReleaseHost = srv.URL
|
||||
t.Cleanup(func() { githubReleaseHost = prevHost })
|
||||
|
||||
if err := verifyChecksumsSignature(context.Background(), "0.0.0", checksumsBody); err != nil {
|
||||
t.Fatalf("verifyChecksumsSignature(good) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureRejectsBadSignature(t *testing.T) {
|
||||
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate keypair: %v", err)
|
||||
}
|
||||
withReleasePubKey(t, base64.StdEncoding.EncodeToString(pub))
|
||||
|
||||
// Sign with a DIFFERENT private key — should be rejected.
|
||||
_, other, _ := ed25519.GenerateKey(rand.Reader)
|
||||
body := []byte("checksum-line\n")
|
||||
badSig := ed25519.Sign(other, body)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintln(w, base64.StdEncoding.EncodeToString(badSig))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
prevHost := githubReleaseHost
|
||||
githubReleaseHost = srv.URL
|
||||
t.Cleanup(func() { githubReleaseHost = prevHost })
|
||||
|
||||
err = verifyChecksumsSignature(context.Background(), "0.0.0", body)
|
||||
if err == nil || !strings.Contains(err.Error(), "verification failed") {
|
||||
t.Fatalf("expected verification failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureMissingFile(t *testing.T) {
|
||||
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
withReleasePubKey(t, base64.StdEncoding.EncodeToString(pub))
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
prevHost := githubReleaseHost
|
||||
githubReleaseHost = srv.URL
|
||||
t.Cleanup(func() { githubReleaseHost = prevHost })
|
||||
|
||||
err := verifyChecksumsSignature(context.Background(), "0.0.0", []byte("body"))
|
||||
if !errors.Is(err, ErrMissingSignature) {
|
||||
t.Fatalf("expected ErrMissingSignature, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSignatureRejectsRaw(t *testing.T) {
|
||||
// 64-byte payload that happens NOT to be valid base64 must error rather
|
||||
// than be silently accepted as a raw signature — the only legitimate
|
||||
// shape is base64-encoded text.
|
||||
raw := make([]byte, ed25519.SignatureSize)
|
||||
for i := range raw {
|
||||
raw[i] = 0xff
|
||||
}
|
||||
if _, err := decodeSignature(raw); err == nil {
|
||||
t.Fatal("expected error from non-base64 64-byte payload")
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ package upgrade
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
|
@ -43,6 +44,13 @@ type Upgrader struct {
|
|||
CurrentVersion string
|
||||
// OnProgress is called with status messages during the upgrade process.
|
||||
OnProgress func(msg string)
|
||||
// AllowUnsigned downgrades a missing checksums.txt.sig to a warning and
|
||||
// continues with SHA256-only verification. Required to downgrade to a
|
||||
// release published before signing was introduced, or to recover from
|
||||
// an accidental release where the workflow's signing step was skipped.
|
||||
// Default false — signature missing is a hard failure when a public
|
||||
// key is embedded.
|
||||
AllowUnsigned bool
|
||||
}
|
||||
|
||||
func (u *Upgrader) log(msg string) {
|
||||
|
|
@ -89,10 +97,21 @@ func (u *Upgrader) Execute(ctx context.Context, targetVersion string) Result {
|
|||
}
|
||||
defer os.Remove(archivePath)
|
||||
|
||||
// 5. Verify checksum
|
||||
u.log("Verifying checksum...")
|
||||
// 5. Verify checksum (and signature, if configured)
|
||||
if SignatureVerificationConfigured() {
|
||||
u.log("Verifying checksum + ed25519 signature...")
|
||||
} else {
|
||||
u.log("Verifying checksum (release signature verification not configured for this build)...")
|
||||
}
|
||||
if err := verifyChecksum(ctx, targetVersion, archivePath); err != nil {
|
||||
return u.fail("checksum: %v", err)
|
||||
if errors.Is(err, ErrMissingSignature) && u.AllowUnsigned {
|
||||
u.log("WARNING: release is unsigned and --allow-unsigned was passed; continuing with SHA256-only verification")
|
||||
if err := verifyChecksumOnly(ctx, targetVersion, archivePath); err != nil {
|
||||
return u.fail("checksum: %v", err)
|
||||
}
|
||||
} else {
|
||||
return u.fail("checksum: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Extract binary
|
||||
|
|
@ -224,7 +243,12 @@ func archiveName(version string) string {
|
|||
return fmt.Sprintf("%s_%s_%s_%s.%s", binaryName, version, runtime.GOOS, runtime.GOARCH, ext)
|
||||
}
|
||||
|
||||
// githubReleaseHost is the base URL used to build release asset URLs. Exposed
|
||||
// as a var (not a const) so tests can point it at an httptest.Server without
|
||||
// touching production behaviour.
|
||||
var githubReleaseHost = "https://github.com"
|
||||
|
||||
// releaseURL returns the download URL for a release asset.
|
||||
func releaseURL(version, filename string) string {
|
||||
return fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", githubRepo, version, filename)
|
||||
return fmt.Sprintf("%s/%s/releases/download/v%s/%s", githubReleaseHost, githubRepo, version, filename)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue