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
22
.github/workflows/release.yml
vendored
22
.github/workflows/release.yml
vendored
|
|
@ -27,6 +27,28 @@ jobs:
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
|
# Empty when RELEASE_SIGNING_PUBKEY variable is unset — goreleaser
|
||||||
|
# accepts it and the resulting binary disables signature checks
|
||||||
|
# (back-compat: pre-signing releases continue to update). Set
|
||||||
|
# RELEASE_SIGNING_PUBKEY (variable) + RELEASE_SIGNING_KEY (secret)
|
||||||
|
# to turn verification on.
|
||||||
|
RELEASE_SIGNING_PUBKEY: ${{ vars.RELEASE_SIGNING_PUBKEY }}
|
||||||
|
|
||||||
|
- name: Sign checksums.txt with ed25519
|
||||||
|
# Reference secrets.X directly — step-level env defined in this same
|
||||||
|
# step is unreliable to read from this step's own if: expression.
|
||||||
|
if: ${{ vars.RELEASE_SIGNING_PUBKEY != '' && secrets.RELEASE_SIGNING_KEY != '' }}
|
||||||
|
env:
|
||||||
|
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go run ./scripts/sign-checksums \
|
||||||
|
-key "$RELEASE_SIGNING_KEY" \
|
||||||
|
-in dist/checksums.txt \
|
||||||
|
-out dist/checksums.txt.sig
|
||||||
|
gh release upload "$RELEASE_TAG" dist/checksums.txt.sig --clobber
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
needs: release
|
needs: release
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ builds:
|
||||||
- -s -w
|
- -s -w
|
||||||
- -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}}
|
- -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}}
|
||||||
- -X github.com/torrentclaw/unarr/internal/sentry.dsn={{ .Env.SENTRY_DSN }}
|
- -X github.com/torrentclaw/unarr/internal/sentry.dsn={{ .Env.SENTRY_DSN }}
|
||||||
|
# Release-signing public key — verified by the self-updater against
|
||||||
|
# checksums.txt.sig. Empty when not configured; in that case
|
||||||
|
# signature verification is skipped and a warning is logged.
|
||||||
|
- -X github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64={{ .Env.RELEASE_SIGNING_PUBKEY }}
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- formats: [tar.gz]
|
- formats: [tar.gz]
|
||||||
|
|
|
||||||
|
|
@ -140,26 +140,29 @@ func (c *Client) OpenSignalStream(ctx context.Context, sessionID string) (*Signa
|
||||||
return stream, nil
|
return stream, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sseMaxLineBytes caps the size of a single SSE line. Real signalling lines
|
||||||
|
// are JSON payloads of a few hundred bytes; 256 KiB is generous enough to
|
||||||
|
// survive a future schema bump but small enough that a hostile or buggy
|
||||||
|
// server cannot grow daemon memory by streaming a single line forever.
|
||||||
|
const sseMaxLineBytes = 256 * 1024
|
||||||
|
|
||||||
|
// sseMaxEventBytes caps the total bytes buffered across the lines of one
|
||||||
|
// SSE event. Without a cap, a peer could send unbounded `data:` continuation
|
||||||
|
// lines and OOM the daemon between blank-line dispatches.
|
||||||
|
const sseMaxEventBytes = 1024 * 1024
|
||||||
|
|
||||||
func (s *SignalEventStream) read() {
|
func (s *SignalEventStream) read() {
|
||||||
defer close(s.done)
|
defer close(s.done)
|
||||||
defer close(s.events)
|
defer close(s.events)
|
||||||
|
|
||||||
reader := bufio.NewReaderSize(s.resp.Body, 16*1024)
|
scanner := bufio.NewScanner(s.resp.Body)
|
||||||
|
scanner.Buffer(make([]byte, 16*1024), sseMaxLineBytes)
|
||||||
|
|
||||||
var dataBuf bytes.Buffer
|
var dataBuf bytes.Buffer
|
||||||
var eventName string
|
var eventName string
|
||||||
|
|
||||||
for {
|
for scanner.Scan() {
|
||||||
line, err := reader.ReadString('\n')
|
line := strings.TrimRight(scanner.Text(), "\r")
|
||||||
if err != nil {
|
|
||||||
if err != io.EOF {
|
|
||||||
select {
|
|
||||||
case s.errs <- err:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
line = strings.TrimRight(line, "\r\n")
|
|
||||||
if line == "" {
|
if line == "" {
|
||||||
// End of an event — dispatch if we have data.
|
// End of an event — dispatch if we have data.
|
||||||
if dataBuf.Len() == 0 {
|
if dataBuf.Len() == 0 {
|
||||||
|
|
@ -190,6 +193,18 @@ func (s *SignalEventStream) read() {
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(line, "data:") {
|
if strings.HasPrefix(line, "data:") {
|
||||||
payload := strings.TrimSpace(line[len("data:"):])
|
payload := strings.TrimSpace(line[len("data:"):])
|
||||||
|
// Refuse to grow the event buffer past the cap. Reset so a
|
||||||
|
// well-formed event after the offender can still be parsed,
|
||||||
|
// and surface an error so SignalLoop reconnects.
|
||||||
|
if dataBuf.Len()+len(payload)+1 > sseMaxEventBytes {
|
||||||
|
dataBuf.Reset()
|
||||||
|
eventName = ""
|
||||||
|
select {
|
||||||
|
case s.errs <- fmt.Errorf("sse: event exceeded %d bytes", sseMaxEventBytes):
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if dataBuf.Len() > 0 {
|
if dataBuf.Len() > 0 {
|
||||||
dataBuf.WriteByte('\n')
|
dataBuf.WriteByte('\n')
|
||||||
}
|
}
|
||||||
|
|
@ -198,6 +213,12 @@ func (s *SignalEventStream) read() {
|
||||||
}
|
}
|
||||||
// id:, retry:, anything else — ignore for now.
|
// id:, retry:, anything else — ignore for now.
|
||||||
}
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
select {
|
||||||
|
case s.errs <- err:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SignalLoop runs an SSE consumer that reconnects automatically on disconnect.
|
// SignalLoop runs an SSE consumer that reconnects automatically on disconnect.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -120,6 +121,48 @@ func TestSignalStreamCloseCancelsRead(t *testing.T) {
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSignalStreamRejectsOversizedEvent verifies that a hostile or buggy
|
||||||
|
// server sending an unbounded `data:` event surfaces an error and stops
|
||||||
|
// the reader instead of growing daemon memory forever.
|
||||||
|
func TestSignalStreamRejectsOversizedEvent(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer test-key" {
|
||||||
|
http.Error(w, "auth", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
flusher := w.(http.Flusher)
|
||||||
|
// Send many data: continuation lines until we blow past the
|
||||||
|
// per-event cap. Each chunk is a short legitimate-looking line.
|
||||||
|
chunk := "data: " + strings.Repeat("x", 4096) + "\n"
|
||||||
|
fmt.Fprint(w, "event: signal\n")
|
||||||
|
for i := 0; i < (sseMaxEventBytes/4096)+8; i++ {
|
||||||
|
fmt.Fprint(w, chunk)
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
<-r.Context().Done()
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(srv.URL, "test-key", "test-ua")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
stream, err := c.OpenSignalStream(ctx, "session-overflow")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
for range stream.Events() {
|
||||||
|
// Should never receive a parsed event — the over-sized buffer must
|
||||||
|
// be rejected before dispatch.
|
||||||
|
}
|
||||||
|
if err := stream.Err(); err == nil {
|
||||||
|
t.Fatal("expected error from oversized event, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPostSignalSendsCorrectBody(t *testing.T) {
|
func TestPostSignalSendsCorrectBody(t *testing.T) {
|
||||||
var bodySeen map[string]any
|
var bodySeen map[string]any
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,7 @@ func runDaemonStart() error {
|
||||||
|
|
||||||
// Create persistent stream server
|
// Create persistent stream server
|
||||||
streamSrv := engine.NewStreamServer(cfg.Download.StreamPort)
|
streamSrv := engine.NewStreamServer(cfg.Download.StreamPort)
|
||||||
|
streamSrv.SetUPnPEnabled(cfg.Download.EnableUPnP)
|
||||||
// Reap HLS tmpdirs left over from a previous daemon run before we start
|
// Reap HLS tmpdirs left over from a previous daemon run before we start
|
||||||
// accepting new sessions. The in-memory registry doesn't survive a
|
// accepting new sessions. The in-memory registry doesn't survive a
|
||||||
// restart, so without this disk usage grows unbounded across restarts.
|
// restart, so without this disk usage grows unbounded across restarts.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
|
|
||||||
func newSelfUpdateCmd() *cobra.Command {
|
func newSelfUpdateCmd() *cobra.Command {
|
||||||
var force bool
|
var force bool
|
||||||
|
var allowUnsigned bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "self-update",
|
Use: "self-update",
|
||||||
|
|
@ -26,18 +27,20 @@ If the daemon is running, it is automatically restarted so the new
|
||||||
version is loaded into memory (otherwise heartbeat would keep
|
version is loaded into memory (otherwise heartbeat would keep
|
||||||
reporting the old version until a manual restart).`,
|
reporting the old version until a manual restart).`,
|
||||||
Example: ` unarr self-update
|
Example: ` unarr self-update
|
||||||
unarr self-update --force`,
|
unarr self-update --force
|
||||||
|
unarr self-update --allow-unsigned # accept releases missing checksums.txt.sig`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return runSelfUpdate(force)
|
return runSelfUpdate(force, allowUnsigned)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
|
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
|
||||||
|
cmd.Flags().BoolVar(&allowUnsigned, "allow-unsigned", false, "continue with SHA256-only verification when checksums.txt.sig is missing")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSelfUpdate(force bool) error {
|
func runSelfUpdate(force, allowUnsigned bool) error {
|
||||||
bold := color.New(color.Bold)
|
bold := color.New(color.Bold)
|
||||||
green := color.New(color.FgGreen)
|
green := color.New(color.FgGreen)
|
||||||
yellow := color.New(color.FgYellow)
|
yellow := color.New(color.FgYellow)
|
||||||
|
|
@ -74,6 +77,7 @@ func runSelfUpdate(force bool) error {
|
||||||
|
|
||||||
upgrader := &upgrade.Upgrader{
|
upgrader := &upgrade.Upgrader{
|
||||||
CurrentVersion: currentClean,
|
CurrentVersion: currentClean,
|
||||||
|
AllowUnsigned: allowUnsigned,
|
||||||
OnProgress: func(msg string) {
|
OnProgress: func(msg string) {
|
||||||
fmt.Printf(" %s\n", msg)
|
fmt.Printf(" %s\n", msg)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
// newUpgradeCmd creates the `unarr upgrade` command as an alias for `self-update`.
|
// newUpgradeCmd creates the `unarr upgrade` command as an alias for `self-update`.
|
||||||
func newUpgradeCmd() *cobra.Command {
|
func newUpgradeCmd() *cobra.Command {
|
||||||
var force bool
|
var force bool
|
||||||
|
var allowUnsigned bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "upgrade",
|
Use: "upgrade",
|
||||||
|
|
@ -18,13 +19,15 @@ This is an alias for 'unarr self-update'. Checks GitHub for the latest
|
||||||
release, verifies the checksum, and replaces the current binary.
|
release, verifies the checksum, and replaces the current binary.
|
||||||
A backup is kept at <binary>.backup.`,
|
A backup is kept at <binary>.backup.`,
|
||||||
Example: ` unarr upgrade
|
Example: ` unarr upgrade
|
||||||
unarr upgrade --force`,
|
unarr upgrade --force
|
||||||
|
unarr upgrade --allow-unsigned`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return runSelfUpdate(force)
|
return runSelfUpdate(force, allowUnsigned)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
|
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
|
||||||
|
cmd.Flags().BoolVar(&allowUnsigned, "allow-unsigned", false, "continue with SHA256-only verification when checksums.txt.sig is missing")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ type DownloadConfig struct {
|
||||||
StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
|
StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
|
||||||
ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
|
ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
|
||||||
StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
|
StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
|
||||||
|
EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in because it exposes the unauthenticated /stream + /hls endpoints to the public internet)
|
||||||
WebRTC WebRTCConfig `toml:"webrtc"`
|
WebRTC WebRTCConfig `toml:"webrtc"`
|
||||||
Transcode TranscodeConfig `toml:"transcode"`
|
Transcode TranscodeConfig `toml:"transcode"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,12 @@ type StreamServer struct {
|
||||||
url string // best single URL (backward compat)
|
url string // best single URL (backward compat)
|
||||||
urls StreamURLs // all available URLs by network type
|
urls StreamURLs // all available URLs by network type
|
||||||
upnpMapping *UPnPMapping
|
upnpMapping *UPnPMapping
|
||||||
disableUPnP bool
|
// enableUPnP gates whether Listen() asks the gateway to publish the
|
||||||
|
// stream port to the WAN. UPnP is opt-in (false by default) because
|
||||||
|
// /stream and /hls have no auth — exposing them on the public internet
|
||||||
|
// would let any scanner enumerate active downloads. LAN and Tailscale
|
||||||
|
// access keep working without UPnP.
|
||||||
|
enableUPnP bool
|
||||||
|
|
||||||
hls *HLSSessionRegistry // HLS sessions served on /hls/<id>/...
|
hls *HLSSessionRegistry // HLS sessions served on /hls/<id>/...
|
||||||
|
|
||||||
|
|
@ -65,10 +70,22 @@ type StreamServer struct {
|
||||||
|
|
||||||
// NewStreamServer creates a stream server bound to the given port.
|
// NewStreamServer creates a stream server bound to the given port.
|
||||||
// Call Listen() to start accepting connections, then SetFile() to serve content.
|
// Call Listen() to start accepting connections, then SetFile() to serve content.
|
||||||
|
//
|
||||||
|
// UPnP is opt-in: call SetUPnPEnabled(true) before Listen() to publish the
|
||||||
|
// stream port on the WAN. Without it, only LAN and Tailscale clients can
|
||||||
|
// reach the server. This matches the security default — /stream and /hls
|
||||||
|
// have no auth, so exposing them to the public internet is something the
|
||||||
|
// operator must explicitly request.
|
||||||
func NewStreamServer(port int) *StreamServer {
|
func NewStreamServer(port int) *StreamServer {
|
||||||
return &StreamServer{port: port, hls: NewHLSSessionRegistry()}
|
return &StreamServer{port: port, hls: NewHLSSessionRegistry()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetUPnPEnabled toggles WAN publishing of the stream port. Call before
|
||||||
|
// Listen(); changes after Listen() are ignored for the active server.
|
||||||
|
func (ss *StreamServer) SetUPnPEnabled(enabled bool) {
|
||||||
|
ss.enableUPnP = enabled
|
||||||
|
}
|
||||||
|
|
||||||
// HLS returns the HLS session registry for this server. Daemon code uses it
|
// HLS returns the HLS session registry for this server. Daemon code uses it
|
||||||
// to register a session when the backend asks for HLS playback.
|
// to register a session when the backend asks for HLS playback.
|
||||||
func (ss *StreamServer) HLS() *HLSSessionRegistry { return ss.hls }
|
func (ss *StreamServer) HLS() *HLSSessionRegistry { return ss.hls }
|
||||||
|
|
@ -122,11 +139,16 @@ func (ss *StreamServer) Listen(ctx context.Context) error {
|
||||||
if tsIP := TailscaleIP(); tsIP != "" {
|
if tsIP := TailscaleIP(); tsIP != "" {
|
||||||
ss.urls.Tailscale = fmt.Sprintf("http://%s:%d/stream", tsIP, ss.port)
|
ss.urls.Tailscale = fmt.Sprintf("http://%s:%d/stream", tsIP, ss.port)
|
||||||
}
|
}
|
||||||
if !ss.disableUPnP {
|
if ss.enableUPnP {
|
||||||
if mapping, err := SetupUPnP(ss.port); err == nil {
|
mapping, err := SetupUPnP(ss.port)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[stream] UPnP setup failed: %v (only LAN/Tailscale clients will reach port %d)", err, ss.port)
|
||||||
|
} else {
|
||||||
ss.upnpMapping = mapping
|
ss.upnpMapping = mapping
|
||||||
ss.urls.Public = fmt.Sprintf("http://%s:%d/stream", mapping.ExternalIP, mapping.ExternalPort)
|
ss.urls.Public = fmt.Sprintf("http://%s:%d/stream", mapping.ExternalIP, mapping.ExternalPort)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("[stream] UPnP disabled — port %d not published to WAN (set downloads.enable_upnp = true to opt in)", ss.port)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best single URL for backward compat: Tailscale > LAN > Public > localhost
|
// Best single URL for backward compat: Tailscale > LAN > Public > localhost
|
||||||
|
|
|
||||||
|
|
@ -384,8 +384,7 @@ func TestStreamServer_Health_WithFile(t *testing.T) {
|
||||||
// nombre de fichero, taskID ni client IP cuando el caller no es loopback.
|
// nombre de fichero, taskID ni client IP cuando el caller no es loopback.
|
||||||
// Protección contra reconnaissance vía LAN / UPnP / Tailscale.
|
// Protección contra reconnaissance vía LAN / UPnP / Tailscale.
|
||||||
func TestStreamServer_Health_NonLoopback_NoLeak(t *testing.T) {
|
func TestStreamServer_Health_NonLoopback_NoLeak(t *testing.T) {
|
||||||
srv := NewStreamServer(0)
|
srv := NewStreamServer(0) // UPnP off by default — keep test hermetic
|
||||||
srv.disableUPnP = true
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if err := srv.Listen(ctx); err != nil {
|
if err := srv.Listen(ctx); err != nil {
|
||||||
t.Fatalf("Listen() error: %v", err)
|
t.Fatalf("Listen() error: %v", err)
|
||||||
|
|
@ -434,8 +433,7 @@ func TestStreamServer_Health_NonLoopback_NoLeak(t *testing.T) {
|
||||||
// session IDs con caracteres ilegales devolviendo 404 (uniforme con sesión
|
// session IDs con caracteres ilegales devolviendo 404 (uniforme con sesión
|
||||||
// inexistente) para no filtrar el formato aceptado a un attacker.
|
// inexistente) para no filtrar el formato aceptado a un attacker.
|
||||||
func TestStreamServer_HLS_InvalidSessionID(t *testing.T) {
|
func TestStreamServer_HLS_InvalidSessionID(t *testing.T) {
|
||||||
srv := NewStreamServer(0)
|
srv := NewStreamServer(0) // UPnP off by default — keep test hermetic
|
||||||
srv.disableUPnP = true
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if err := srv.Listen(ctx); err != nil {
|
if err := srv.Listen(ctx); err != nil {
|
||||||
t.Fatalf("Listen() error: %v", err)
|
t.Fatalf("Listen() error: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -185,8 +185,7 @@ func TestStreamServerByteTracking(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := NewStreamServer(0)
|
srv := NewStreamServer(0) // UPnP off by default — keep test hermetic
|
||||||
srv.disableUPnP = true
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if err := srv.Listen(ctx); err != nil {
|
if err := srv.Listen(ctx); err != nil {
|
||||||
t.Fatalf("listen: %v", err)
|
t.Fatalf("listen: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package upgrade
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"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.
|
// 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 {
|
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
|
// Download checksums.txt
|
||||||
url := releaseURL(version, "checksums.txt")
|
url := releaseURL(version, "checksums.txt")
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
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)
|
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>"
|
// Parse checksums.txt — format: "<sha256> <filename>"
|
||||||
expectedName := archiveName(version)
|
expectedName := archiveName(version)
|
||||||
var expectedHash string
|
var expectedHash string
|
||||||
|
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := bufio.NewScanner(bytes.NewReader(checksumsContent))
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
parts := strings.Fields(line)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -43,6 +44,13 @@ type Upgrader struct {
|
||||||
CurrentVersion string
|
CurrentVersion string
|
||||||
// OnProgress is called with status messages during the upgrade process.
|
// OnProgress is called with status messages during the upgrade process.
|
||||||
OnProgress func(msg string)
|
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) {
|
func (u *Upgrader) log(msg string) {
|
||||||
|
|
@ -89,10 +97,21 @@ func (u *Upgrader) Execute(ctx context.Context, targetVersion string) Result {
|
||||||
}
|
}
|
||||||
defer os.Remove(archivePath)
|
defer os.Remove(archivePath)
|
||||||
|
|
||||||
// 5. Verify checksum
|
// 5. Verify checksum (and signature, if configured)
|
||||||
u.log("Verifying checksum...")
|
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 {
|
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
|
// 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)
|
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.
|
// releaseURL returns the download URL for a release asset.
|
||||||
func releaseURL(version, filename string) string {
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
scripts/gen-release-key/main.go
Normal file
37
scripts/gen-release-key/main.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// gen-release-key generates an ed25519 keypair for signing release artifacts.
|
||||||
|
// Run once per repository, then store the printed values:
|
||||||
|
//
|
||||||
|
// RELEASE_SIGNING_KEY → GitHub Actions secret (private key, base64)
|
||||||
|
// RELEASE_SIGNING_PUBKEY → GitHub Actions variable (public key, base64)
|
||||||
|
//
|
||||||
|
// The public key is injected into the binary at build time via the
|
||||||
|
// goreleaser ldflags entry that resolves
|
||||||
|
// `github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64`.
|
||||||
|
// The private key is used by the workflow's "Sign checksums.txt" step.
|
||||||
|
//
|
||||||
|
// Build and run:
|
||||||
|
//
|
||||||
|
// go run ./scripts/gen-release-key
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println("# Add the following to your GitHub repository:")
|
||||||
|
fmt.Println("# - Settings → Secrets and variables → Actions → New repository secret")
|
||||||
|
fmt.Println("# RELEASE_SIGNING_KEY = <PRIVATE_KEY_BASE64 below>")
|
||||||
|
fmt.Println("# - Settings → Secrets and variables → Actions → New repository variable")
|
||||||
|
fmt.Println("# RELEASE_SIGNING_PUBKEY = <PUBLIC_KEY_BASE64 below>")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("PUBLIC_KEY_BASE64=%s\n", base64.StdEncoding.EncodeToString(pub))
|
||||||
|
fmt.Printf("PRIVATE_KEY_BASE64=%s\n", base64.StdEncoding.EncodeToString(priv))
|
||||||
|
}
|
||||||
60
scripts/sign-checksums/main.go
Normal file
60
scripts/sign-checksums/main.go
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
// sign-checksums signs the dist/checksums.txt file with an ed25519 private
|
||||||
|
// key and writes the base64-encoded signature to the path given by -out.
|
||||||
|
//
|
||||||
|
// Usage (from release workflow):
|
||||||
|
//
|
||||||
|
// go run ./scripts/sign-checksums \
|
||||||
|
// -key "$RELEASE_SIGNING_KEY" \
|
||||||
|
// -in dist/checksums.txt \
|
||||||
|
// -out dist/checksums.txt.sig
|
||||||
|
//
|
||||||
|
// The companion CLI verifier (internal/upgrade/signature.go) requires the
|
||||||
|
// signature to be base64 text, so emitting base64 + trailing newline makes
|
||||||
|
// the artifact safe to inspect with `cat` / the GitHub release UI.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
keyB64 := flag.String("key", "", "base64-encoded ed25519 private key (PrivateKeySize = 64 bytes)")
|
||||||
|
in := flag.String("in", "", "path to file to sign")
|
||||||
|
out := flag.String("out", "", "path to write the base64-encoded signature")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *keyB64 == "" || *in == "" || *out == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: sign-checksums -key <base64> -in <path> -out <path>")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyBytes, err := base64.StdEncoding.DecodeString(*keyB64)
|
||||||
|
if err != nil {
|
||||||
|
fail("decode key: %v", err)
|
||||||
|
}
|
||||||
|
if len(keyBytes) != ed25519.PrivateKeySize {
|
||||||
|
fail("private key size %d, expected %d", len(keyBytes), ed25519.PrivateKeySize)
|
||||||
|
}
|
||||||
|
priv := ed25519.PrivateKey(keyBytes)
|
||||||
|
|
||||||
|
content, err := os.ReadFile(*in)
|
||||||
|
if err != nil {
|
||||||
|
fail("read input: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := ed25519.Sign(priv, content)
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(sig) + "\n"
|
||||||
|
if err := os.WriteFile(*out, []byte(encoded), 0o644); err != nil {
|
||||||
|
fail("write signature: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Signed %s (%d bytes) → %s\n", *in, len(content), *out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue