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