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
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)))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue