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.
112 lines
3.9 KiB
Go
112 lines
3.9 KiB
Go
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)))
|
|
}
|