Require signed unified agent release assets

This commit is contained in:
rcourtman 2026-04-22 02:00:29 +01:00
parent 7b1520b760
commit 7be844f23a
24 changed files with 976 additions and 115 deletions

View file

@ -560,6 +560,7 @@ jobs:
./scripts/build-release.sh ${{ needs.prepare.outputs.version }}
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}
- name: Post-build health check
run: |
@ -713,6 +714,9 @@ jobs:
TAG="${{ needs.prepare.outputs.tag }}"
gh release upload "${TAG}" release/checksums.txt --clobber
gh release upload "${TAG}" release/*.sha256 --clobber
if ls release/*.sig 1> /dev/null 2>&1; then
gh release upload "${TAG}" release/*.sig --clobber
fi
- name: Upload release assets
env:

View file

@ -10,8 +10,11 @@ The agent's self-update mechanism is critical for security and stability. To pre
The agent verifies a SHA-256 checksum of the downloaded binary. The server must provide
`X-Checksum-Sha256`; updates are rejected if the header is missing or mismatched.
### 2. Signature Verification (Optional)
When present, Ed25519 signatures (`X-Signature-Ed25519`) add an extra validation layer on top of checksums.
### 2. Signature Verification
Release builds embed trusted Ed25519 update public keys and require
`X-Signature-Ed25519` in addition to the checksum header. Updates are rejected
when the signature is missing or does not verify against the embedded trust
root.
### 3. Pre-Flight Checks
To prevent "brick-updates"—bad updates that crash immediately and require manual recovery—agents perform pre-flight validation before replacing the running executable.
@ -19,8 +22,9 @@ To prevent "brick-updates"—bad updates that crash immediately and require manu
Unified agent (`pulse-agent`):
1. Download new binary.
2. Verify checksum (required).
3. Validate binary magic (ELF/Mach-O/PE) and size limits (100MB max).
4. Make executable and swap atomically.
3. Verify the Ed25519 release signature when trusted update keys are embedded.
4. Validate binary magic (ELF/Mach-O/PE) and size limits (100MB max).
5. Make executable and swap atomically.
## API Security

View file

@ -190,13 +190,14 @@ an add-only capacity posture.
catches up, rather than as a fresh enrolment competing for another
monitored-system slot.
4. Keep shared agent-side TLS identity fail-closed across `cmd/pulse-agent/main.go`, `internal/hostagent/`, `internal/agentupdate/`, and adjacent remote-config transport. Self-signed deployments may use a canonical pinned Pulse server certificate fingerprint, but lifecycle transport must route that pin through reporting, enrollment, command websocket, remote-config, and self-update clients instead of widening `PULSE_INSECURE_SKIP_VERIFY` into a blanket MITM carve-out.
5. Keep shared `internal/api/` helper edits isolated from agent lifecycle semantics: Patrol-specific status transport or alert-trigger wiring changes in shared handlers must not bleed into auto-register, installer, or fleet-control behavior unless this contract moves in the same slice.
5. Keep release-grade updater trust fail-closed across `internal/agentupdate/`, `internal/dockeragent/`, and the shared `internal/api/unified_agent.go` download helpers. When release builds embed trusted update signing keys, published agent binaries and installer assets must carry detached `.sig` sidecars and the updater/runtime path must require `X-Signature-Ed25519` in addition to `X-Checksum-Sha256` instead of silently downgrading to checksum-only trust.
6. Keep shared `internal/api/` helper edits isolated from agent lifecycle semantics: Patrol-specific status transport or alert-trigger wiring changes in shared handlers must not bleed into auto-register, installer, or fleet-control behavior unless this contract moves in the same slice.
The same isolation rule applies to AI settings payload work in `internal/api/ai_handlers.go`: provider auth fields, masked-secret echoes, and provider-test model selection remain AI/runtime plus API-contract ownership and must not be reinterpreted as lifecycle setup or registration semantics just because they share backend helper layers.
The same shared-helper rule now covers SSO outbound discovery and metadata fetches plus credential-file loads in `internal/api/sso_outbound.go`, `internal/api/saml_service.go`, and `internal/api/oidc_service.go`: lifecycle-adjacent setup or auth work may depend on that shared trust boundary, but it must not fork a second HTTP client, redirect policy, or file-read rule inside lifecycle-local flows.
5. Keep legacy Unified Agent compatibility names explicitly secondary when touching shared `internal/api/` runtime helpers: the legacy host-route family and `host-agent:*` scope names may remain as ingress or migration aliases, but they must not retake primary ownership in router state, live runtime scope checks, handler commentary, or operator-facing guidance.
6. Add or change the unified agent CLI entrypoint, version/help exit semantics, or startup argument/error routing through `cmd/pulse-agent/main.go`.
7. Add or change installer flags, persisted service arguments, or upgrade-safe re-entry behavior through `scripts/install.sh` and `scripts/install.ps1`.
8. Add or change profile management, the extracted agent profiles runtime owner, the top-level infrastructure ledger, the pure unified-agent inventory/install model, the connections-ledger workspace shell, the unified ConnectionEditor and its per-type credential slots, route model, shared install section owner, the shared direct-node/discovery infrastructure settings owners plus their model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, VMware platform-connection management, the shared monitored-system admission preview shell for those platform connections, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/ConnectionsTable.tsx`, `frontend-modern/src/components/Settings/connectionsTableModel.ts`, `frontend-modern/src/components/Settings/useConnectionsLedger.ts`, `frontend-modern/src/components/Settings/useConnectionRowActions.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`, `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`, `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`, `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`. Phase 9 retired the legacy reporting/inventory surface (InfrastructureOperationsController, InfrastructureInventorySection, InfrastructureActiveRowDetails, InfrastructureIgnoredRowDetails, InfrastructureStopMonitoringDialog, useInfrastructureReportingState) and the per-type shells (PlatformConnectionsWorkspace, ProxmoxSettingsPanel, ProxmoxDirectWorkspace, ProxmoxConfiguredNodesTable, ProxmoxDirectConnectionsCard, ProxmoxDiscoveryResultsCard, ProxmoxDeleteNodeDialog, ProxmoxNodeModalStack, NodeModal shell, TrueNASSettingsPanel, VMwareSettingsPanel, useProxmoxDirectWorkspaceState); lifecycle extensions must route through the unified aggregator ledger plus ConnectionEditor credential slots rather than reintroducing those retired surfaces.
6. Keep legacy Unified Agent compatibility names explicitly secondary when touching shared `internal/api/` runtime helpers: the legacy host-route family and `host-agent:*` scope names may remain as ingress or migration aliases, but they must not retake primary ownership in router state, live runtime scope checks, handler commentary, or operator-facing guidance.
7. Add or change the unified agent CLI entrypoint, version/help exit semantics, or startup argument/error routing through `cmd/pulse-agent/main.go`.
8. Add or change installer flags, persisted service arguments, or upgrade-safe re-entry behavior through `scripts/install.sh` and `scripts/install.ps1`.
9. Add or change profile management, the extracted agent profiles runtime owner, the top-level infrastructure ledger, the pure unified-agent inventory/install model, the connections-ledger workspace shell, the unified ConnectionEditor and its per-type credential slots, route model, shared install section owner, the shared direct-node/discovery infrastructure settings owners plus their model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, VMware platform-connection management, the shared monitored-system admission preview shell for those platform connections, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/ConnectionsTable.tsx`, `frontend-modern/src/components/Settings/connectionsTableModel.ts`, `frontend-modern/src/components/Settings/useConnectionsLedger.ts`, `frontend-modern/src/components/Settings/useConnectionRowActions.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`, `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`, `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`, `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`. Phase 9 retired the legacy reporting/inventory surface (InfrastructureOperationsController, InfrastructureInventorySection, InfrastructureActiveRowDetails, InfrastructureIgnoredRowDetails, InfrastructureStopMonitoringDialog, useInfrastructureReportingState) and the per-type shells (PlatformConnectionsWorkspace, ProxmoxSettingsPanel, ProxmoxDirectWorkspace, ProxmoxConfiguredNodesTable, ProxmoxDirectConnectionsCard, ProxmoxDiscoveryResultsCard, ProxmoxDeleteNodeDialog, ProxmoxNodeModalStack, NodeModal shell, TrueNASSettingsPanel, VMwareSettingsPanel, useProxmoxDirectWorkspaceState); lifecycle extensions must route through the unified aggregator ledger plus ConnectionEditor credential slots rather than reintroducing those retired surfaces.
Those lifecycle-owned settings hooks may consume websocket state only through `frontend-modern/src/contexts/appRuntime.ts`; they must not import `frontend-modern/src/App.tsx` or recreate root-shell providers.
Public demo and other read-only settings posture must stay reporting-first
on that same lifecycle-owned workspace boundary: infrastructure workspace
@ -1582,6 +1583,13 @@ for self-signed deployments, and that pin must flow through reporting,
enrollment, command websocket, remote-config, and self-update transport
instead of widening `PULSE_INSECURE_SKIP_VERIFY` into an all-path MITM
carve-out.
Release-grade updater continuity must also stay fail-closed on signed assets.
When release builds embed trusted update signing keys through
`internal/updatesignature`, `internal/agentupdate/` and
`internal/dockeragent/` must require both `X-Checksum-Sha256` and
`X-Signature-Ed25519`, and `internal/api/unified_agent.go` must only serve
published release installers and agent binaries from local or proxied assets
that carry the matching detached signature sidecar.
That same unified-agent runtime boundary also owns vendor-aware host identity.
When gopsutil reports generic Linux platform fields on NAS appliances,
`internal/hostagent/` must prefer canonical platform files such as Synology DSM

View file

@ -172,7 +172,8 @@ the canonical monitored-system blocked payload.
6. Route dedicated unified-resource timeline and facet-bundle reads through `frontend-modern/src/api/resources.ts`, `internal/api/resources.go`, and `internal/api/contract_test.go` together so the backend facet contract and the frontend client stay aligned on one timeline-first surface, while capability and relationship detail stays backend-owned for AI correlation and change detection
7. Route unified-resource list ordering through `internal/api/resources.go`, `internal/api/contract_test.go`, and the owned unified-resource registry helpers together; list payloads must stay deterministic for equal-name resources by carrying one canonical `name -> type -> id` tie-break across cold seed, REST pagination, and websocket-backed refreshes instead of inheriting map order or page-local re-sorts
That same shared API contract also owns the external resource `type`, canonical display name, and cluster identity published through `/api/resources` and `/api/state`; the websocket/state hydrate path must not emit legacy aliases or raw store labels once the unified resource contract has normalized them.
8. Route canonical AI intelligence summary and resource-intelligence reads through `frontend-modern/src/api/ai.ts`, `frontend-modern/src/stores/aiIntelligence.ts`, `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts`, `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`, `frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx`, the Patrol-owned section files under `frontend-modern/src/features/patrol/`, `frontend-modern/src/pages/AIIntelligence.tsx`, `internal/api/ai_handlers.go`, and `internal/api/contract_test.go` together so the summary card, store normalization owner, runtime hook, feature shell, section owners, route shell, and backend payload stay aligned on one governed surface, including the canonical recent-changes slice
8. Route unified-agent installer and binary download headers through `internal/api/unified_agent.go` and `internal/api/contract_test.go` together; published release downloads must keep the canonical `X-Checksum-Sha256` plus `X-Signature-Ed25519` contract whether the asset is served locally or proxied from the matching GitHub release, instead of leaving callers to infer trust from source location alone.
9. Route canonical AI intelligence summary and resource-intelligence reads through `frontend-modern/src/api/ai.ts`, `frontend-modern/src/stores/aiIntelligence.ts`, `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts`, `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`, `frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx`, the Patrol-owned section files under `frontend-modern/src/features/patrol/`, `frontend-modern/src/pages/AIIntelligence.tsx`, `internal/api/ai_handlers.go`, and `internal/api/contract_test.go` together so the summary card, store normalization owner, runtime hook, feature shell, section owners, route shell, and backend payload stay aligned on one governed surface, including the canonical recent-changes slice
while keeping the learning counters backend-only coverage, so the summary page keeps Patrol health and findings primary and renders timeline, correlation, and policy-posture data as secondary investigation context rather than as a separate headline product metric
and the Patrol findings empty-state behavior, so `0 active findings` only renders as a healthy frontend conclusion when the same governed AI summary contract still reports healthy overall health; degraded or not-fully-verified health predictions must flow through to the Patrol findings surface instead of being replaced by page-local "looks healthy" copy
and the Patrol assessment headline plus compact summary-strip behavior, so the same governed AI summary contract decides whether the page leads with verified health, issues detected, coverage incomplete, or another attention state instead of letting count-only page fragments emit a stale `No issues found` conclusion
@ -2047,6 +2048,11 @@ contract: when `/install.sh` or `/install.ps1` cannot be served locally, the
backend must proxy the install script asset from the exact GitHub release that
matches `serverVersion` and must fail closed for dev or unreleased builds
rather than serving branch-tip installer logic.
That same response contract now also owns signed release-asset headers:
published agent-binary and installer downloads served through
`internal/api/unified_agent.go` must surface both `X-Checksum-Sha256` and
`X-Signature-Ed25519`, and release-tagged local assets must not bypass that
header contract just because the binary or script is present on disk.
That same transport rule is now explicit about prerelease classes too: only
stable tags and explicit RC prerelease tags without build metadata qualify as
published install-script release assets. Working-line dev prereleases such as

View file

@ -975,3 +975,11 @@ routing on both sides instead of relying only on generic API fallback
coverage: update transport changes must continue to carry the direct
`updates-api-surface` installability proof together with a direct
API-contract proof path.
That same governed release-promotion boundary now also owns detached agent and
installer signatures. `scripts/build-release.sh`,
`scripts/release_update_key.go`, `scripts/release_ldflags.sh`, and
`.github/workflows/create-release.yml` must derive the embedded update trust
root from the governed release signing key, emit `.sig` sidecars for shipped
agent binaries and installer assets, and upload those signatures with the
matching release packet so published RC/stable downloads can keep the updater
trust chain fail-closed instead of downgrading to checksum-only trust.

View file

@ -454,7 +454,10 @@ querying, and the operator-facing storage health presentation layer.
versions such as `v6.0.0-dev` and build-metadata versions must not be
treated as published GitHub release assets; only stable or explicit RC
tags may back the shared installer fallback that those adjacent surfaces
inherit.
inherit. Published release-tagged local assets on that shared boundary
must also preserve their detached `.sig` sidecars so recovery- and
storage-adjacent flows do not silently downgrade installer or agent
download trust back to unsigned local files during upgrade or repair.
15. Keep storage summary chart identity and sticky-shell behavior on the
shared storage path. Pool rows, disk rows, storage summary cards, and
storage detail charts must all address history through the canonical

View file

@ -23,6 +23,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agenttls"
"github.com/rcourtman/pulse-go-rewrite/internal/updatesignature"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog"
)
@ -43,6 +44,7 @@ const (
authorizationHeader = "Authorization"
bearerTokenPrefix = "Bearer "
checksumSHA256Header = "X-Checksum-Sha256"
signatureHeader = "X-Signature-Ed25519"
// updateRequestMaxAttempts is the number of attempts for transient update HTTP failures.
updateRequestMaxAttempts = 3
@ -659,6 +661,7 @@ func (u *Updater) performUpdateWithExecPath(ctx context.Context, execPath string
// Verify checksum if provided
checksumHeader := strings.TrimSpace(resp.Header.Get(checksumSHA256Header))
signatureHeaderValue := strings.TrimSpace(resp.Header.Get(signatureHeader))
// Resolve symlinks to get the real path for atomic rename
realExecPath, err := evalSymlinksFn(execPath)
@ -721,6 +724,16 @@ func (u *Updater) performUpdateWithExecPath(ctx context.Context, execPath string
}
u.logger.Debug().Str("checksum", downloadChecksum).Msg("checksum verified")
if updatesignature.HasTrustedPublicKeys() {
if signatureHeaderValue == "" {
return fmt.Errorf("server did not provide signature header (%s); refusing update for security", signatureHeader)
}
if err := updatesignature.VerifyFile(tmpPath, signatureHeaderValue); err != nil {
return fmt.Errorf("signature verification failed: %w", err)
}
u.logger.Debug().Msg("signature verified")
}
// Make executable
if err := chmodFn(tmpPath, 0755); err != nil {
return fmt.Errorf("failed to chmod: %w", err)

View file

@ -2,6 +2,9 @@ package agentupdate
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
@ -11,6 +14,8 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/updatesignature"
)
const testPEMCertificate = `-----BEGIN CERTIFICATE-----
@ -162,6 +167,77 @@ func TestNew_UsesPinnedServerFingerprintForHTTPTransport(t *testing.T) {
}
}
func configureTrustedUpdateSigningKey(t *testing.T) ed25519.PrivateKey {
t.Helper()
original := updatesignature.EmbeddedTrustedPublicKeys
t.Cleanup(func() { updatesignature.EmbeddedTrustedPublicKeys = original })
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate signing key: %v", err)
}
updatesignature.EmbeddedTrustedPublicKeys = httpHeaderPublicKey(t, publicKey)
return privateKey
}
func httpHeaderPublicKey(t *testing.T, publicKey ed25519.PublicKey) string {
t.Helper()
return base64.StdEncoding.EncodeToString(publicKey)
}
func signedUpdateHeader(t *testing.T, data []byte, privateKey ed25519.PrivateKey) string {
t.Helper()
signature, err := updatesignature.SignBytes(data, privateKey)
if err != nil {
t.Fatalf("sign update: %v", err)
}
return signature
}
func TestUpdater_performUpdateWithExecPath_RequiresSignatureWhenTrustedKeysConfigured(t *testing.T) {
privateKey := configureTrustedUpdateSigningKey(t)
data := testBinary()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(checksumSHA256Header, checksum(data))
w.Header().Set(signatureHeader, signedUpdateHeader(t, data, privateKey))
_, _ = w.Write(data)
}))
defer server.Close()
_, execPath := writeTempExec(t)
u := newUpdaterForTest(server.URL)
u.client = server.Client()
origRestart := restartProcessFn
t.Cleanup(func() { restartProcessFn = origRestart })
restartProcessFn = func(string) error { return nil }
if err := u.performUpdateWithExecPath(context.Background(), execPath); err != nil {
t.Fatalf("performUpdateWithExecPath: %v", err)
}
}
func TestUpdater_performUpdateWithExecPath_RejectsMissingSignatureWhenTrustedKeysConfigured(t *testing.T) {
_ = configureTrustedUpdateSigningKey(t)
data := testBinary()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(checksumSHA256Header, checksum(data))
_, _ = w.Write(data)
}))
defer server.Close()
_, execPath := writeTempExec(t)
u := newUpdaterForTest(server.URL)
u.client = server.Client()
if err := u.performUpdateWithExecPath(context.Background(), execPath); err == nil || !strings.Contains(err.Error(), signatureHeader) {
t.Fatalf("expected missing signature error, got %v", err)
}
}
func TestUpdater_CheckAndUpdate_EarlyReturns(t *testing.T) {
u := New(Config{Disabled: true})
u.performUpdateFn = func(ctx context.Context) error {

View file

@ -7488,6 +7488,49 @@ func TestContract_ProxmoxInstallCommandNormalizesTrailingSlashBaseURL(t *testing
}
}
func TestContract_DownloadUnifiedAgentReleaseAssetIncludesSignatureHeader(t *testing.T) {
router, tempDir := setupUnifiedAgentRouter(t)
router.serverVersion = "v6.0.0"
binContent := validTestUnifiedAgentBinary("linux-amd64")
binPath := filepath.Join(tempDir, "bin", "pulse-agent-linux-amd64")
if err := os.WriteFile(binPath, binContent, 0o755); err != nil {
t.Fatalf("write binary: %v", err)
}
if err := os.WriteFile(binPath+".sig", []byte("signed-agent"), 0o644); err != nil {
t.Fatalf("write signature: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=linux-amd64", nil)
w := httptest.NewRecorder()
router.handleDownloadUnifiedAgent(w, req)
if got := w.Header().Get(signatureHeaderName); got != "signed-agent" {
t.Fatalf("agent download signature header = %q, want %q", got, "signed-agent")
}
}
func TestContract_DownloadInstallScriptReleaseAssetIncludesSignatureHeader(t *testing.T) {
router, tempDir := setupUnifiedAgentRouter(t)
router.serverVersion = "v6.0.0"
scriptPath := filepath.Join(tempDir, "scripts", "install.sh")
if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho ok\n"), 0o755); err != nil {
t.Fatalf("write install script: %v", err)
}
if err := os.WriteFile(scriptPath+".sig", []byte("signed-install-script"), 0o644); err != nil {
t.Fatalf("write install signature: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
w := httptest.NewRecorder()
router.handleDownloadUnifiedInstallScript(w, req)
if got := w.Header().Get(signatureHeaderName); got != "signed-install-script" {
t.Fatalf("install script signature header = %q, want %q", got, "signed-install-script")
}
}
func TestContract_SystemSettingsResponseJSONSnapshot(t *testing.T) {
payload := EmptySystemSettingsResponse()
payload.SystemSettings = config.SystemSettings{

View file

@ -5,6 +5,7 @@ import (
"archive/zip"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@ -24,6 +25,8 @@ const (
canonicalUnifiedAgentReportPath = "/api/agents/agent/report"
legacyUnifiedAgentReportPath = "/api/agents/host/report"
defaultInstallScriptReleaseRepo = "rcourtman/Pulse"
checksumHeaderName = "X-Checksum-Sha256"
signatureHeaderName = "X-Signature-Ed25519"
)
func installScriptReleaseRepo() string {
@ -47,16 +50,16 @@ func githubLatestReleaseAPIURL() string {
}
func (r *Router) handleDownloadUnifiedInstallScript(w http.ResponseWriter, req *http.Request) {
handleDownloadInstallScriptCommon(w, req, "/opt/pulse/scripts/install.sh", filepath.Join(r.projectRoot, "scripts", "install.sh"), "install.sh", "text/x-shellscript", r.proxyInstallScriptFromGitHub)
handleDownloadInstallScriptCommon(w, req, r.serverVersion, "/opt/pulse/scripts/install.sh", filepath.Join(r.projectRoot, "scripts", "install.sh"), "install.sh", "text/x-shellscript", r.proxyInstallScriptFromGitHub)
}
func (r *Router) handleDownloadUnifiedInstallScriptPS(w http.ResponseWriter, req *http.Request) {
handleDownloadInstallScriptCommon(w, req, "/opt/pulse/scripts/install.ps1", filepath.Join(r.projectRoot, "scripts", "install.ps1"), "install.ps1", "text/plain", r.proxyInstallScriptFromGitHub)
handleDownloadInstallScriptCommon(w, req, r.serverVersion, "/opt/pulse/scripts/install.ps1", filepath.Join(r.projectRoot, "scripts", "install.ps1"), "install.ps1", "text/plain", r.proxyInstallScriptFromGitHub)
}
type proxyFunc func(http.ResponseWriter, *http.Request, string)
func handleDownloadInstallScriptCommon(w http.ResponseWriter, req *http.Request, prodPath, fallbackPath, scriptName, contentType string, fallbackProxy proxyFunc) {
func handleDownloadInstallScriptCommon(w http.ResponseWriter, req *http.Request, serverVersion, prodPath, fallbackPath, scriptName, contentType string, fallbackProxy proxyFunc) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
@ -76,8 +79,18 @@ func handleDownloadInstallScriptCommon(w http.ResponseWriter, req *http.Request,
}
}
signature, sigErr := readReleaseAssetSignature(scriptPath)
if sigErr != nil && isPublishedReleaseAssetVersion(serverVersion) {
log.Warn().Err(sigErr).Str("path", scriptPath).Msg("Signed install script unavailable locally, proxying from GitHub releases")
fallbackProxy(w, req, scriptName)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", "inline; filename=\""+scriptName+"\"")
if signature != "" {
w.Header().Set(signatureHeaderName, signature)
}
http.ServeFile(w, req, scriptPath)
}
@ -187,7 +200,17 @@ func (r *Router) handleDownloadUnifiedAgent(w http.ResponseWriter, req *http.Req
}
defer file.Close()
w.Header().Set("X-Checksum-Sha256", checksum)
signature, sigErr := readReleaseAssetSignature(candidate)
if sigErr != nil && isPublishedReleaseAssetVersion(r.serverVersion) {
log.Warn().Err(sigErr).Str("path", candidate).Msg("Skipping unsigned local unified agent binary")
invalidCandidates = append(invalidCandidates, fmt.Sprintf("%s (%v)", candidate, sigErr))
continue
}
w.Header().Set(checksumHeaderName, checksum)
if signature != "" {
w.Header().Set(signatureHeaderName, signature)
}
http.ServeContent(w, req, filepath.Base(candidate), info.ModTime(), file)
return
}
@ -252,6 +275,7 @@ func (r *Router) proxyAgentBinaryFromGitHub(w http.ResponseWriter, req *http.Req
binaryName += ".exe"
}
githubURL := githubReleaseDownloadURL(binaryName)
signatureURL := githubReleaseDownloadURL(binaryName + ".sig")
log.Info().Str("arch", normalized).Str("url", githubURL).Msg("Local agent binary not found, proxying from GitHub releases")
@ -277,7 +301,13 @@ func (r *Router) proxyAgentBinaryFromGitHub(w http.ResponseWriter, req *http.Req
http.Error(w, "Failed to read agent binary", http.StatusInternalServerError)
return
}
serveProxiedAgentBinary(w, content, checksum, "github-proxy")
signature, sigErr := fetchReleaseAssetContent(req.Context(), client, signatureURL, 16*1024)
if sigErr != nil {
log.Error().Err(sigErr).Str("url", signatureURL).Msg("Failed to fetch agent binary signature from GitHub")
http.Error(w, "Failed to fetch agent binary signature", http.StatusServiceUnavailable)
return
}
serveProxiedAgentBinaryWithSignature(w, content, checksum, strings.TrimSpace(string(signature)), "github-proxy")
return
}
@ -293,7 +323,13 @@ func (r *Router) proxyAgentBinaryFromGitHub(w http.ResponseWriter, req *http.Req
http.Error(w, "Agent binary not found on GitHub", http.StatusNotFound)
return
}
serveProxiedAgentBinary(w, archiveContent, checksum, "github-proxy-archive")
signature, sigErr := fetchReleaseAssetContent(req.Context(), client, signatureURL, 16*1024)
if sigErr != nil {
log.Error().Err(sigErr).Str("url", signatureURL).Msg("Failed to fetch agent binary signature from GitHub")
http.Error(w, "Failed to fetch agent binary signature", http.StatusServiceUnavailable)
return
}
serveProxiedAgentBinaryWithSignature(w, archiveContent, checksum, strings.TrimSpace(string(signature)), "github-proxy-archive")
}
const maxAgentBinarySize = 100 * 1024 * 1024
@ -312,7 +348,14 @@ func readBinaryWithChecksum(body io.Reader) ([]byte, string, error) {
}
func serveProxiedAgentBinary(w http.ResponseWriter, content []byte, checksum, servedFrom string) {
w.Header().Set("X-Checksum-Sha256", checksum)
serveProxiedAgentBinaryWithSignature(w, content, checksum, "", servedFrom)
}
func serveProxiedAgentBinaryWithSignature(w http.ResponseWriter, content []byte, checksum, signature, servedFrom string) {
w.Header().Set(checksumHeaderName, checksum)
if strings.TrimSpace(signature) != "" {
w.Header().Set(signatureHeaderName, strings.TrimSpace(signature))
}
w.Header().Set("X-Served-From", servedFrom)
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(content)
@ -398,6 +441,54 @@ func fetchLatestReleaseTag(client *http.Client) (string, error) {
return tag, nil
}
func isPublishedReleaseAssetVersion(rawVersion string) bool {
rawVersion = strings.TrimSpace(rawVersion)
if rawVersion == "" || strings.EqualFold(rawVersion, "dev") {
return false
}
version, err := updates.ParseVersion(rawVersion)
if err != nil {
return false
}
return version.IsPublishedReleaseAssetVersion()
}
func readReleaseAssetSignature(path string) (string, error) {
signaturePath := path + ".sig"
data, err := os.ReadFile(signaturePath)
if err != nil {
return "", fmt.Errorf("read release signature %s: %w", signaturePath, err)
}
signature := strings.TrimSpace(string(data))
if signature == "" {
return "", fmt.Errorf("release signature %s is empty", signaturePath)
}
return signature, nil
}
func fetchReleaseAssetContent(ctx context.Context, client *http.Client, url string, limit int64) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create release asset request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("release asset returned status %d", resp.StatusCode)
}
content, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return nil, err
}
if int64(len(content)) > limit {
return nil, fmt.Errorf("release asset exceeded size limit")
}
return content, nil
}
func extractFromTarGz(archive []byte, entryName string) ([]byte, error) {
gzReader, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
@ -519,6 +610,13 @@ func (r *Router) proxyInstallScriptFromGitHub(w http.ResponseWriter, req *http.R
return
}
signatureContent, sigErr := fetchReleaseAssetContent(req.Context(), client, githubURL+".sig", 16*1024)
if sigErr != nil {
log.Error().Err(sigErr).Str("url", githubURL+".sig").Msg("Failed to fetch install script signature from GitHub")
http.Error(w, "Failed to fetch install script signature", http.StatusServiceUnavailable)
return
}
// Read the script content
content, err := io.ReadAll(resp.Body)
if err != nil {
@ -536,6 +634,7 @@ func (r *Router) proxyInstallScriptFromGitHub(w http.ResponseWriter, req *http.R
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", "inline; filename=\""+scriptName+"\"")
w.Header().Set("X-Served-From", "github-fallback")
w.Header().Set(signatureHeaderName, strings.TrimSpace(string(signatureContent)))
if req.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return

View file

@ -40,6 +40,44 @@ func newTestInstallScriptClient(t *testing.T, expectedMethod, expectedURL string
}
}
type expectedHTTPExchange struct {
Method string
URL string
Status int
Body string
Err error
}
func newTestInstallScriptClientSequence(t *testing.T, exchanges []expectedHTTPExchange) *http.Client {
t.Helper()
var index int
return &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if index >= len(exchanges) {
t.Fatalf("unexpected extra request: %s %s", req.Method, req.URL.String())
}
exchange := exchanges[index]
index++
if exchange.Method != "" && req.Method != exchange.Method {
t.Fatalf("unexpected method at request %d: %s", index, req.Method)
}
if exchange.URL != "" && req.URL.String() != exchange.URL {
t.Fatalf("unexpected URL at request %d: %s", index, req.URL.String())
}
if exchange.Err != nil {
return nil, exchange.Err
}
return &http.Response{
StatusCode: exchange.Status,
Status: fmt.Sprintf("%d %s", exchange.Status, http.StatusText(exchange.Status)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(exchange.Body)),
}, nil
}),
}
}
func TestDownloadUnifiedInstallScript_MethodNotAllowed(t *testing.T) {
router := &Router{}
@ -71,7 +109,10 @@ func TestDownloadUnifiedInstallScript_ProxyFallback(t *testing.T) {
router.serverVersion = "v6.0.0-rc.1"
expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/install.sh"
payload := "#!/bin/bash\necho hi"
router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, expectedURL, http.StatusOK, payload, nil)
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
{Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: payload},
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-install-script"},
})
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
w := httptest.NewRecorder()
@ -87,6 +128,9 @@ func TestDownloadUnifiedInstallScript_ProxyFallback(t *testing.T) {
if got := w.Header().Get("Content-Type"); got != "text/x-shellscript" {
t.Fatalf("unexpected Content-Type: %q", got)
}
if got := w.Header().Get(signatureHeaderName); got != "signed-install-script" {
t.Fatalf("unexpected signature header: %q", got)
}
if !strings.Contains(w.Header().Get("Content-Disposition"), "install.sh") {
t.Fatalf("missing Content-Disposition filename")
}
@ -102,7 +146,10 @@ func TestDownloadUnifiedInstallScript_ProxyFallbackUsesConfiguredRepo(t *testing
router.serverVersion = "v6.0.0-rc.1"
expectedURL := "https://github.com/example/pulse-fork/releases/download/v6.0.0-rc.1/install.sh"
payload := "#!/bin/bash\necho hi"
router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, expectedURL, http.StatusOK, payload, nil)
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
{Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: payload},
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-install-script"},
})
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
w := httptest.NewRecorder()
@ -119,7 +166,10 @@ func TestDownloadUnifiedInstallScriptPS_ProxyFallback(t *testing.T) {
router.serverVersion = "6.0.0"
expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v6.0.0/install.ps1"
payload := "Write-Host 'hi'"
router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, expectedURL, http.StatusOK, payload, nil)
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
{Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: payload},
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-install-script"},
})
req := httptest.NewRequest(http.MethodGet, "/install.ps1", nil)
w := httptest.NewRecorder()
@ -213,7 +263,10 @@ func TestDownloadUnifiedInstallScript_ProxyFallbackPreservesHEAD(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
router.serverVersion = "v6.0.0-rc.1"
expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/install.sh"
router.installScriptClient = newTestInstallScriptClient(t, http.MethodHead, expectedURL, http.StatusOK, "", nil)
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
{Method: http.MethodHead, URL: expectedURL, Status: http.StatusOK, Body: ""},
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-install-script"},
})
req := httptest.NewRequest(http.MethodHead, "/install.sh", nil)
w := httptest.NewRecorder()
@ -226,6 +279,9 @@ func TestDownloadUnifiedInstallScript_ProxyFallbackPreservesHEAD(t *testing.T) {
if w.Body.Len() != 0 {
t.Fatalf("expected empty HEAD response body")
}
if got := w.Header().Get(signatureHeaderName); got != "signed-install-script" {
t.Fatalf("unexpected signature header: %q", got)
}
}
func TestDownloadUnifiedAgent_MethodNotAllowed(t *testing.T) {

View file

@ -137,6 +137,26 @@ func TestDownloadUnifiedAgent_Local_SpecificArch(t *testing.T) {
assert.Equal(t, string(binContent), w.Body.String())
}
func TestDownloadUnifiedAgent_LocalReleaseBinaryIncludesSignatureHeader(t *testing.T) {
router, tempDir := setupUnifiedAgentRouter(t)
router.serverVersion = "v6.0.0"
binContent := validTestUnifiedAgentBinary("linux-amd64")
binPath := filepath.Join(tempDir, "bin", "pulse-agent-linux-amd64")
err := os.WriteFile(binPath, binContent, 0755)
require.NoError(t, err)
err = os.WriteFile(binPath+".sig", []byte("signed-local-agent"), 0644)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=linux-amd64", nil)
w := httptest.NewRecorder()
router.handleDownloadUnifiedAgent(w, req)
require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "signed-local-agent", w.Header().Get(signatureHeaderName))
}
func TestDownloadUnifiedAgent_SkipsStaleLocalBinaryAndProxies(t *testing.T) {
router, tempDir := setupUnifiedAgentRouter(t)
@ -145,7 +165,10 @@ func TestDownloadUnifiedAgent_SkipsStaleLocalBinaryAndProxies(t *testing.T) {
binaryContent := "fresh github binary"
expectedURL := "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-linux-amd64"
router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, expectedURL, http.StatusOK, binaryContent, nil)
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
{Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: binaryContent},
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-agent"},
})
req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=linux-amd64", nil)
w := httptest.NewRecorder()
@ -155,6 +178,7 @@ func TestDownloadUnifiedAgent_SkipsStaleLocalBinaryAndProxies(t *testing.T) {
require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, binaryContent, w.Body.String())
assert.Equal(t, "github-proxy", w.Header().Get("X-Served-From"))
assert.Equal(t, "signed-agent", w.Header().Get(signatureHeaderName))
}
func TestDownloadUnifiedAgent_DevModeRejectsStaleLocalBinary(t *testing.T) {
@ -182,7 +206,10 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub(t *testing.T) {
// Set up a mock HTTP client to simulate GitHub response
binaryContent := "fake binary content for proxy test"
expectedURL := "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-linux-amd64"
router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, expectedURL, http.StatusOK, binaryContent, nil)
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
{Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: binaryContent},
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-agent"},
})
req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=linux-amd64", nil)
w := httptest.NewRecorder()
@ -198,6 +225,7 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub(t *testing.T) {
hash := sha256.Sum256([]byte(binaryContent))
expectedChecksum := hex.EncodeToString(hash[:])
assert.Equal(t, expectedChecksum, w.Header().Get("X-Checksum-Sha256"))
assert.Equal(t, "signed-agent", w.Header().Get(signatureHeaderName))
}
func TestDownloadUnifiedAgent_ProxyFromGitHub_UsesConfiguredRepo(t *testing.T) {
@ -207,7 +235,10 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_UsesConfiguredRepo(t *testing.T) {
binaryContent := "fake binary content for proxy test"
expectedURL := "https://github.com/example/pulse-fork/releases/latest/download/pulse-agent-linux-amd64"
router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, expectedURL, http.StatusOK, binaryContent, nil)
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
{Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: binaryContent},
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-agent"},
})
req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=linux-amd64", nil)
w := httptest.NewRecorder()
@ -223,7 +254,10 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_Windows(t *testing.T) {
binaryContent := "MZ fake windows binary"
expectedURL := "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-windows-amd64.exe"
router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, expectedURL, http.StatusOK, binaryContent, nil)
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
{Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: binaryContent},
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-agent"},
})
req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=windows-amd64", nil)
w := httptest.NewRecorder()
@ -233,6 +267,7 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_Windows(t *testing.T) {
require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, binaryContent, w.Body.String())
assert.NotEmpty(t, w.Header().Get("X-Checksum-Sha256"))
assert.Equal(t, "signed-agent", w.Header().Get(signatureHeaderName))
}
func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_Darwin(t *testing.T) {
@ -241,6 +276,7 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_Darwin(t *testing.
binaryContent := []byte("darwin arm64 binary payload")
archivePayload := buildTestTarGz(t, "pulse-agent-darwin-arm64", binaryContent)
binaryURL := "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-darwin-arm64"
signatureURL := binaryURL + ".sig"
latestURL := "https://api.github.com/repos/rcourtman/Pulse/releases/latest"
archiveURL := "https://github.com/rcourtman/Pulse/releases/download/v9.9.9/pulse-agent-v9.9.9-darwin-arm64.tar.gz"
@ -268,6 +304,13 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_Darwin(t *testing.
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader(archivePayload)),
}, nil
case signatureURL:
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("signed-agent")),
}, nil
default:
t.Fatalf("unexpected URL: %s", req.URL.String())
return nil, nil
@ -287,6 +330,7 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_Darwin(t *testing.
hash := sha256.Sum256(binaryContent)
expectedChecksum := hex.EncodeToString(hash[:])
assert.Equal(t, expectedChecksum, w.Header().Get("X-Checksum-Sha256"))
assert.Equal(t, "signed-agent", w.Header().Get(signatureHeaderName))
}
func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_UsesConfiguredRepo(t *testing.T) {
@ -297,6 +341,7 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_UsesConfiguredRepo
binaryContent := []byte("darwin arm64 binary payload")
archivePayload := buildTestTarGz(t, "pulse-agent-darwin-arm64", binaryContent)
binaryURL := "https://github.com/example/pulse-fork/releases/latest/download/pulse-agent-darwin-arm64"
signatureURL := binaryURL + ".sig"
latestURL := "https://api.github.com/repos/example/pulse-fork/releases/latest"
archiveURL := "https://github.com/example/pulse-fork/releases/download/v9.9.9/pulse-agent-v9.9.9-darwin-arm64.tar.gz"
@ -324,6 +369,13 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_UsesConfiguredRepo
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader(archivePayload)),
}, nil
case signatureURL:
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("signed-agent")),
}, nil
default:
t.Fatalf("unexpected URL: %s", req.URL.String())
return nil, nil
@ -339,13 +391,16 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_UsesConfiguredRepo
require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "github-proxy-archive", w.Header().Get("X-Served-From"))
assert.Equal(t, string(binaryContent), w.Body.String())
assert.Equal(t, "signed-agent", w.Header().Get(signatureHeaderName))
}
func TestDownloadUnifiedAgent_ProxyFromGitHub_NotFound(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
// GitHub returns 404 for the binary
router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, "", http.StatusNotFound, "", nil)
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
{Method: http.MethodGet, URL: "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-linux-amd64", Status: http.StatusNotFound, Body: ""},
{Method: http.MethodGet, URL: "https://api.github.com/repos/rcourtman/Pulse/releases/latest", Status: http.StatusNotFound, Body: ""},
})
req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=linux-amd64", nil)
w := httptest.NewRecorder()
@ -359,7 +414,7 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_Error(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
// GitHub is unreachable
router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, "", 0, "", errors.New("connection refused"))
router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-linux-amd64", 0, "", errors.New("connection refused"))
req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=linux-amd64", nil)
w := httptest.NewRecorder()

View file

@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/updatesignature"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
)
@ -461,14 +462,14 @@ func (a *Agent) selfUpdate(ctx context.Context) error {
// Verify cryptographic signature (X-Signature-Ed25519 header)
signatureHeader := strings.TrimSpace(resp.Header.Get("X-Signature-Ed25519"))
if signatureHeader != "" {
if updatesignature.HasTrustedPublicKeys() {
if signatureHeader == "" {
return fmt.Errorf("server did not provide cryptographic signature (X-Signature-Ed25519); refusing update for security")
}
if err := verifyFileSignature(tmpPath, signatureHeader); err != nil {
return fmt.Errorf("signature verification failed: %w", err)
}
a.logger.Info().Msg("Self-update: cryptographic signature verified")
} else {
// For now, only warn if missing. In strict mode, we would error here.
a.logger.Warn().Msg("Self-update: server did not provide cryptographic signature (X-Signature-Ed25519)")
}
// Make temp file executable

View file

@ -3,7 +3,10 @@ package dockeragent
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"io"
@ -16,6 +19,7 @@ import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/updatesignature"
"github.com/rs/zerolog"
)
@ -458,6 +462,29 @@ func sha256Hex(data []byte) string {
return hex.EncodeToString(sum[:])
}
func configureTrustedUpdateKeys(t *testing.T) ed25519.PrivateKey {
t.Helper()
original := updatesignature.EmbeddedTrustedPublicKeys
t.Cleanup(func() { updatesignature.EmbeddedTrustedPublicKeys = original })
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate signing key: %v", err)
}
updatesignature.EmbeddedTrustedPublicKeys = base64.StdEncoding.EncodeToString(publicKey)
return privateKey
}
func signedSelfUpdateHeader(t *testing.T, body []byte, privateKey ed25519.PrivateKey) string {
t.Helper()
signature, err := updatesignature.SignBytes(body, privateKey)
if err != nil {
t.Fatalf("sign update body: %v", err)
}
return signature
}
func TestSelfUpdate(t *testing.T) {
swap(t, &selfUpdateRetrySleepFn, func(context.Context, time.Duration) error { return nil })
@ -1257,6 +1284,79 @@ func TestSelfUpdate(t *testing.T) {
}
})
t.Run("signature verified when trusted keys configured", func(t *testing.T) {
privateKey := configureTrustedUpdateKeys(t)
body := elfBytes()
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{
"X-Checksum-Sha256": []string{sha256Hex(body)},
"X-Signature-Ed25519": []string{signedSelfUpdateHeader(t, body, privateKey)},
},
}, nil
})}
agent := &Agent{
logger: zerolog.Nop(),
targets: []TargetConfig{{URL: "http://example.com", Token: "token"}},
httpClients: map[bool]*http.Client{
false: client,
},
}
dir := t.TempDir()
execPath := filepath.Join(dir, "exec")
if err := os.WriteFile(execPath, elfBytes(), 0700); err != nil {
t.Fatalf("write exec: %v", err)
}
swap(t, &osExecutableFn, func() (string, error) {
return execPath, nil
})
swap(t, &syscallExecFn, func(string, []string, []string) error {
return nil
})
swap(t, &execCommandContextFn, func(ctx context.Context, name string, arg ...string) *exec.Cmd {
return exec.Command("echo", "ok")
})
if err := agent.selfUpdate(context.Background()); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("signature missing when trusted keys configured", func(t *testing.T) {
_ = configureTrustedUpdateKeys(t)
body := elfBytes()
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"X-Checksum-Sha256": []string{sha256Hex(body)}},
}, nil
})}
agent := &Agent{
logger: zerolog.Nop(),
targets: []TargetConfig{{URL: "http://example.com", Token: "token"}},
httpClients: map[bool]*http.Client{
false: client,
},
}
dir := t.TempDir()
execPath := filepath.Join(dir, "exec")
if err := os.WriteFile(execPath, elfBytes(), 0700); err != nil {
t.Fatalf("write exec: %v", err)
}
swap(t, &osExecutableFn, func() (string, error) {
return execPath, nil
})
if err := agent.selfUpdate(context.Background()); err == nil || !strings.Contains(err.Error(), "X-Signature-Ed25519") {
t.Fatalf("expected missing signature error, got %v", err)
}
})
t.Run("arch fallback to default", func(t *testing.T) {
body := elfBytes()
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {

View file

@ -1,79 +1,16 @@
package dockeragent
import (
"crypto/ed25519"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"os"
"github.com/rcourtman/pulse-go-rewrite/internal/updatesignature"
)
// trustedPublicKeysPEM contains a list of trusted release public keys.
// In a real build, these would be injected via ldflags.
// Using a list allows for key rotation (start signing with new key, retire old key later).
var trustedPublicKeysPEM = []string{
`-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAlbXZQRx8jgMzwpXbbjOGcnA+9TG0lms/auxbPzY+Tdo=
-----END PUBLIC KEY-----`,
}
// verifySignature checks if the provided binary data matches the signature
// using ANY of the trusted Ed25519 public keys.
// using the trusted release public keys embedded into release builds.
func verifySignature(binaryData []byte, signatureBase64 string) error {
if signatureBase64 == "" {
return errors.New("missing signature")
}
// Decode the signature once
sigBytes, err := base64.StdEncoding.DecodeString(signatureBase64)
if err != nil {
return fmt.Errorf("invalid base64 signature: %w", err)
}
var lastErr error
// Try each trusted key
for _, keyPEM := range trustedPublicKeysPEM {
block, _ := pem.Decode([]byte(keyPEM))
if block == nil || block.Type != "PUBLIC KEY" {
lastErr = errors.New("failed to decode one of the trusted public keys")
continue
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
lastErr = fmt.Errorf("failed to parse trusted public key: %w", err)
continue
}
edPub, ok := pub.(ed25519.PublicKey)
if !ok {
lastErr = errors.New("trusted key is not an Ed25519 public key")
continue
}
// If verification succeeds, we are valid!
if ed25519.Verify(edPub, binaryData, sigBytes) {
return nil
}
lastErr = errors.New("signature verification failed for key")
}
// If we're here, no key verified the signature
if lastErr != nil {
return fmt.Errorf("cryptographic signature verification failed against all trusted keys")
}
return errors.New("no trusted keys available for verification")
return updatesignature.VerifyBytes(binaryData, signatureBase64)
}
// verifyFileSignature reads the file and verifies its signature.
func verifyFileSignature(path string, signatureBase64 string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read file for verification: %w", err)
}
return verifySignature(data, signatureBase64)
return updatesignature.VerifyFile(path, signatureBase64)
}

View file

@ -9,12 +9,14 @@ import (
"encoding/pem"
"os"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/updatesignature"
)
func TestVerifySignature(t *testing.T) {
originalKeys := trustedPublicKeysPEM
originalKeys := updatesignature.EmbeddedTrustedPublicKeys
defer func() {
trustedPublicKeysPEM = originalKeys
updatesignature.EmbeddedTrustedPublicKeys = originalKeys
}()
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
@ -25,7 +27,7 @@ func TestVerifySignature(t *testing.T) {
if err != nil {
t.Fatalf("marshal public key: %v", err)
}
trustedPublicKeysPEM = []string{string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}))}
updatesignature.EmbeddedTrustedPublicKeys = string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}))
data := []byte("payload")
sig := ed25519.Sign(privateKey, data)
@ -50,12 +52,12 @@ func TestVerifySignature(t *testing.T) {
}
func TestVerifySignatureInvalidKeys(t *testing.T) {
originalKeys := trustedPublicKeysPEM
originalKeys := updatesignature.EmbeddedTrustedPublicKeys
defer func() {
trustedPublicKeysPEM = originalKeys
updatesignature.EmbeddedTrustedPublicKeys = originalKeys
}()
trustedPublicKeysPEM = []string{"not-pem"}
updatesignature.EmbeddedTrustedPublicKeys = "not-pem"
if err := verifySignature([]byte("data"), base64.StdEncoding.EncodeToString([]byte("sig"))); err == nil {
t.Fatal("expected error for invalid pem")
}
@ -68,7 +70,7 @@ func TestVerifySignatureInvalidKeys(t *testing.T) {
if err != nil {
t.Fatalf("marshal rsa: %v", err)
}
trustedPublicKeysPEM = []string{string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}))}
updatesignature.EmbeddedTrustedPublicKeys = string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}))
if err := verifySignature([]byte("data"), base64.StdEncoding.EncodeToString([]byte("sig"))); err == nil {
t.Fatal("expected error for non-ed25519 key")
@ -76,9 +78,9 @@ func TestVerifySignatureInvalidKeys(t *testing.T) {
}
func TestVerifyFileSignature(t *testing.T) {
originalKeys := trustedPublicKeysPEM
originalKeys := updatesignature.EmbeddedTrustedPublicKeys
defer func() {
trustedPublicKeysPEM = originalKeys
updatesignature.EmbeddedTrustedPublicKeys = originalKeys
}()
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
@ -89,7 +91,7 @@ func TestVerifyFileSignature(t *testing.T) {
if err != nil {
t.Fatalf("marshal public key: %v", err)
}
trustedPublicKeysPEM = []string{string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}))}
updatesignature.EmbeddedTrustedPublicKeys = string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}))
file := filepathJoin(t)
data := []byte("file")

View file

@ -0,0 +1,174 @@
package updatesignature
import (
"crypto/ed25519"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"os"
"strings"
)
// EmbeddedTrustedPublicKeys is injected into release builds via ldflags.
// The value is expected to be a comma-separated list of base64-encoded Ed25519
// public keys or PKIX-encoded public keys.
var EmbeddedTrustedPublicKeys string
// HasTrustedPublicKeys reports whether a build embeds trusted update keys.
func HasTrustedPublicKeys() bool {
return strings.TrimSpace(EmbeddedTrustedPublicKeys) != ""
}
// DecodePrivateKey decodes a base64-encoded Ed25519 private key or seed.
func DecodePrivateKey(encoded string) (ed25519.PrivateKey, error) {
encoded = strings.TrimSpace(encoded)
if encoded == "" {
return nil, errors.New("empty signing key")
}
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("invalid base64 signing key: %w", err)
}
switch len(raw) {
case ed25519.PrivateKeySize:
return ed25519.PrivateKey(raw), nil
case ed25519.SeedSize:
return ed25519.NewKeyFromSeed(raw), nil
default:
return nil, fmt.Errorf("invalid signing key length: %d", len(raw))
}
}
// PublicKeyString returns the raw Ed25519 public key as base64.
func PublicKeyString(privateKey ed25519.PrivateKey) (string, error) {
if len(privateKey) != ed25519.PrivateKeySize {
return "", errors.New("invalid signing key")
}
publicKey, ok := privateKey.Public().(ed25519.PublicKey)
if !ok {
return "", errors.New("failed to derive public key")
}
return base64.StdEncoding.EncodeToString(publicKey), nil
}
// SignBytes signs a blob and returns a base64-encoded Ed25519 signature.
func SignBytes(data []byte, privateKey ed25519.PrivateKey) (string, error) {
if len(privateKey) != ed25519.PrivateKeySize {
return "", errors.New("invalid signing key")
}
signature := ed25519.Sign(privateKey, data)
return base64.StdEncoding.EncodeToString(signature), nil
}
// SignFile signs the file at path and returns a base64-encoded Ed25519 signature.
func SignFile(path string, privateKey ed25519.PrivateKey) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read file for signing: %w", err)
}
return SignBytes(data, privateKey)
}
// VerifyBytes verifies a base64-encoded Ed25519 signature against the embedded trusted keys.
func VerifyBytes(data []byte, signatureBase64 string) error {
signatureBase64 = strings.TrimSpace(signatureBase64)
if signatureBase64 == "" {
return errors.New("missing signature")
}
signature, err := base64.StdEncoding.DecodeString(signatureBase64)
if err != nil {
return fmt.Errorf("invalid base64 signature: %w", err)
}
keys, err := trustedPublicKeys()
if err != nil {
return fmt.Errorf("load trusted update public keys: %w", err)
}
for _, key := range keys {
if ed25519.Verify(key, data, signature) {
return nil
}
}
return errors.New("signature verification failed against all trusted keys")
}
// VerifyFile verifies a file against a base64-encoded Ed25519 signature.
func VerifyFile(path string, signatureBase64 string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read file for verification: %w", err)
}
return VerifyBytes(data, signatureBase64)
}
func trustedPublicKeys() ([]ed25519.PublicKey, error) {
raw := strings.TrimSpace(EmbeddedTrustedPublicKeys)
if raw == "" {
return nil, errors.New("no trusted update keys available")
}
var keys []ed25519.PublicKey
if strings.Contains(raw, "BEGIN PUBLIC KEY") {
for {
block, rest := pem.Decode([]byte(raw))
if block == nil {
break
}
raw = string(rest)
if block.Type != "PUBLIC KEY" {
continue
}
key, err := parsePublicKeyBytes(block.Bytes)
if err != nil {
return nil, err
}
keys = append(keys, key)
}
} else {
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
decoded, err := base64.StdEncoding.DecodeString(part)
if err != nil {
return nil, fmt.Errorf("invalid base64 public key: %w", err)
}
key, err := parsePublicKeyBytes(decoded)
if err != nil {
return nil, err
}
keys = append(keys, key)
}
}
if len(keys) == 0 {
return nil, errors.New("no trusted update keys available")
}
return keys, nil
}
func parsePublicKeyBytes(raw []byte) (ed25519.PublicKey, error) {
if len(raw) == ed25519.PublicKeySize {
return ed25519.PublicKey(raw), nil
}
publicKey, err := x509.ParsePKIXPublicKey(raw)
if err != nil {
return nil, fmt.Errorf("failed to parse trusted public key: %w", err)
}
edPublicKey, ok := publicKey.(ed25519.PublicKey)
if !ok {
return nil, errors.New("trusted key is not an Ed25519 public key")
}
return edPublicKey, nil
}

View file

@ -0,0 +1,130 @@
package updatesignature
import (
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"os"
"path/filepath"
"testing"
)
func TestSignAndVerifyBytes(t *testing.T) {
original := EmbeddedTrustedPublicKeys
t.Cleanup(func() { EmbeddedTrustedPublicKeys = original })
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
EmbeddedTrustedPublicKeys = base64.StdEncoding.EncodeToString(publicKey)
signature, err := SignBytes([]byte("payload"), privateKey)
if err != nil {
t.Fatalf("sign bytes: %v", err)
}
if err := VerifyBytes([]byte("payload"), signature); err != nil {
t.Fatalf("verify bytes: %v", err)
}
if err := VerifyBytes([]byte("payload"), ""); err == nil {
t.Fatal("expected missing signature error")
}
if err := VerifyBytes([]byte("payload"), "!!!"); err == nil {
t.Fatal("expected invalid signature encoding error")
}
if err := VerifyBytes([]byte("different"), signature); err == nil {
t.Fatal("expected signature mismatch")
}
}
func TestSignAndVerifyFile(t *testing.T) {
original := EmbeddedTrustedPublicKeys
t.Cleanup(func() { EmbeddedTrustedPublicKeys = original })
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
EmbeddedTrustedPublicKeys = base64.StdEncoding.EncodeToString(publicKey)
path := filepath.Join(t.TempDir(), "payload.bin")
if err := os.WriteFile(path, []byte("payload"), 0o600); err != nil {
t.Fatalf("write payload: %v", err)
}
signature, err := SignFile(path, privateKey)
if err != nil {
t.Fatalf("sign file: %v", err)
}
if err := VerifyFile(path, signature); err != nil {
t.Fatalf("verify file: %v", err)
}
}
func TestPublicKeyString(t *testing.T) {
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
encoded, err := PublicKeyString(privateKey)
if err != nil {
t.Fatalf("public key string: %v", err)
}
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("decode public key: %v", err)
}
if len(decoded) != ed25519.PublicKeySize {
t.Fatalf("decoded public key length = %d, want %d", len(decoded), ed25519.PublicKeySize)
}
}
func TestTrustedPublicKeysRejectsInvalidInput(t *testing.T) {
original := EmbeddedTrustedPublicKeys
t.Cleanup(func() { EmbeddedTrustedPublicKeys = original })
EmbeddedTrustedPublicKeys = "not-base64"
if _, err := trustedPublicKeys(); err == nil {
t.Fatal("expected invalid base64 key error")
}
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate rsa key: %v", err)
}
rsaBytes, err := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey)
if err != nil {
t.Fatalf("marshal rsa key: %v", err)
}
EmbeddedTrustedPublicKeys = base64.StdEncoding.EncodeToString(rsaBytes)
if _, err := trustedPublicKeys(); err == nil {
t.Fatal("expected non-ed25519 key error")
}
}
func TestTrustedPublicKeysAcceptsPEM(t *testing.T) {
original := EmbeddedTrustedPublicKeys
t.Cleanup(func() { EmbeddedTrustedPublicKeys = original })
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
publicKeyBytes, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
EmbeddedTrustedPublicKeys = string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicKeyBytes}))
keys, err := trustedPublicKeys()
if err != nil {
t.Fatalf("trusted public keys: %v", err)
}
if len(keys) != 1 {
t.Fatalf("trusted public keys length = %d, want 1", len(keys))
}
}

View file

@ -69,6 +69,44 @@ else
license_ldflags_args=(--license-public-key "${PULSE_LICENSE_PUBLIC_KEY}")
fi
# Require update signing for release-grade agent and installer verification.
# Explicitly opt out with PULSE_ALLOW_MISSING_UPDATE_SIGNING_KEY=true for local-only debugging.
update_ldflags_args=()
if [[ -z "${PULSE_UPDATE_SIGNING_KEY:-}" ]]; then
if [[ "${PULSE_ALLOW_MISSING_UPDATE_SIGNING_KEY:-false}" == "true" ]]; then
echo "Warning: PULSE_UPDATE_SIGNING_KEY not set; continuing because PULSE_ALLOW_MISSING_UPDATE_SIGNING_KEY=true."
else
echo "Error: PULSE_UPDATE_SIGNING_KEY is required for release builds." >&2
echo "Set PULSE_ALLOW_MISSING_UPDATE_SIGNING_KEY=true only for local non-release debugging." >&2
exit 1
fi
else
update_public_key=$(go run ./scripts/release_update_key.go public-key --private-key "${PULSE_UPDATE_SIGNING_KEY}")
if [[ -z "${update_public_key}" ]]; then
echo "Error: failed to derive update signing public key." >&2
exit 1
fi
update_ldflags_args=(--update-public-keys "${update_public_key}")
fi
sign_release_file() {
local file="$1"
if [[ -z "${PULSE_UPDATE_SIGNING_KEY:-}" ]]; then
return 0
fi
go run ./scripts/release_update_key.go sign --private-key "${PULSE_UPDATE_SIGNING_KEY}" --file "${file}" > "${file}.sig"
}
sign_directory_release_assets() {
local dir="$1"
if [[ -z "${PULSE_UPDATE_SIGNING_KEY:-}" ]]; then
return 0
fi
while IFS= read -r -d '' file; do
sign_release_file "${file}"
done < <(find "${dir}" -maxdepth 1 -type f -print0)
}
# Clean previous builds
rm -rf $BUILD_DIR $RELEASE_DIR
mkdir -p $BUILD_DIR $RELEASE_DIR
@ -78,7 +116,7 @@ echo "Building frontend..."
npm --prefix frontend-modern ci
npm --prefix frontend-modern run build
agent_ldflags="$(./scripts/release_ldflags.sh agent --version "v${VERSION}")"
agent_ldflags="$(./scripts/release_ldflags.sh agent --version "v${VERSION}" "${update_ldflags_args[@]}")"
# Build unified agents for every supported platform/architecture
echo "Building unified agents for all platforms..."
@ -142,7 +180,7 @@ for i in "${!build_order[@]}"; do
build_time=$(date -u '+%Y-%m-%d_%H:%M:%S')
git_commit=$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')
server_ldflags="$(./scripts/release_ldflags.sh server --version "v${VERSION}" --build-time "${build_time}" --git-commit "${git_commit}" "${license_ldflags_args[@]}")"
server_ldflags="$(./scripts/release_ldflags.sh server --version "v${VERSION}" --build-time "${build_time}" --git-commit "${git_commit}" "${license_ldflags_args[@]}" "${update_ldflags_args[@]}")"
# Build backend binary with version info
# -tags release disables dev-mode env-var bypasses (PULSE_DEV, PULSE_MOCK_MODE,
@ -189,6 +227,9 @@ for build_name in "${build_order[@]}"; do
chmod 755 "$staging_dir/scripts/"*.sh
chmod 755 "$staging_dir/scripts/"*.ps1 2>/dev/null || true
echo "$VERSION" > "$staging_dir/VERSION"
sign_directory_release_assets "$staging_dir/bin"
sign_directory_release_assets "$staging_dir/scripts"
sign_release_file "$staging_dir/VERSION"
# Create tarball from staging directory
cd "$staging_dir"
@ -270,6 +311,9 @@ chmod +x "$universal_dir/bin/pulse-agent"
# Add VERSION file
echo "$VERSION" > "$universal_dir/VERSION"
sign_directory_release_assets "$universal_dir/bin"
sign_directory_release_assets "$universal_dir/scripts"
sign_release_file "$universal_dir/VERSION"
# Package standalone unified agent binaries (all platforms)
# Linux
@ -363,6 +407,12 @@ fi
if compgen -G "pulse-*.zip" > /dev/null; then
checksum_files+=( pulse-*.zip )
fi
if compgen -G "pulse-agent-linux-*" > /dev/null; then
checksum_files+=( pulse-agent-linux-* )
fi
if compgen -G "pulse-agent-freebsd-*" > /dev/null; then
checksum_files+=( pulse-agent-freebsd-* )
fi
if compgen -G "pulse-*.exe" > /dev/null; then
checksum_files+=( pulse-*.exe )
fi
@ -390,6 +440,10 @@ else
printf '%s %s\n' "$checksum" "$filename" > "${filename}.sha256"
done <<< "$checksum_output"
for artifact in "${checksum_files[@]}" checksums.txt; do
sign_release_file "${artifact}"
done
if [ -n "${SIGNING_KEY_ID:-}" ]; then
if command -v gpg >/dev/null 2>&1; then
echo "Signing checksums with GPG key ${SIGNING_KEY_ID}..."

View file

@ -34,8 +34,11 @@ func TestBuildReleaseUsesV6InstallScripts(t *testing.T) {
}
requiredScriptWiring := []string{
`agent_ldflags="$(./scripts/release_ldflags.sh agent --version "v${VERSION}")"`,
`server_ldflags="$(./scripts/release_ldflags.sh server --version "v${VERSION}" --build-time "${build_time}" --git-commit "${git_commit}" "${license_ldflags_args[@]}")"`,
`agent_ldflags="$(./scripts/release_ldflags.sh agent --version "v${VERSION}" "${update_ldflags_args[@]}")"`,
`server_ldflags="$(./scripts/release_ldflags.sh server --version "v${VERSION}" --build-time "${build_time}" --git-commit "${git_commit}" "${license_ldflags_args[@]}" "${update_ldflags_args[@]}")"`,
`PULSE_UPDATE_SIGNING_KEY`,
`go run ./scripts/release_update_key.go public-key --private-key "${PULSE_UPDATE_SIGNING_KEY}"`,
`sign_release_file "${artifact}"`,
}
for _, needle := range requiredScriptWiring {
if !strings.Contains(script, needle) {
@ -65,6 +68,7 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) {
`gh release upload "${TAG}" release/install.sh --clobber`,
`if [ -f release/install.ps1 ]; then`,
`gh release upload "${TAG}" release/install.ps1 --clobber`,
`gh release upload "${TAG}" release/*.sig --clobber`,
`gh api "repos/${{ github.repository }}/releases?per_page=100" --paginate`,
`git push origin "refs/tags/${TAG}" --force`,
`-F target_commitish="${HEAD_SHA}"`,

View file

@ -13,6 +13,7 @@ func TestReleaseLdflagsServerIncludesCanonicalBuildMetadata(t *testing.T) {
"--build-time", "2026-03-15T20:00:00Z",
"--git-commit", "abcdef1",
"--license-public-key", "ZmFrZS1saWNlbnNlLWtleQ==",
"--update-public-keys", "ZmFrZS11cGRhdGUta2V5",
)
output, err := cmd.CombinedOutput()
@ -29,6 +30,7 @@ func TestReleaseLdflagsServerIncludesCanonicalBuildMetadata(t *testing.T) {
"-X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=v6.0.0-rc.1",
"-X github.com/rcourtman/pulse-go-rewrite/pkg/licensing.EmbeddedPublicKey=ZmFrZS1saWNlbnNlLWtleQ==",
"-X github.com/rcourtman/pulse-go-rewrite/internal/license.EmbeddedPublicKey=ZmFrZS1saWNlbnNlLWtleQ==",
"-X github.com/rcourtman/pulse-go-rewrite/internal/updatesignature.EmbeddedTrustedPublicKeys=ZmFrZS11cGRhdGUta2V5",
}
for _, needle := range required {
if !strings.Contains(ldflags, needle) {
@ -41,6 +43,7 @@ func TestReleaseLdflagsAgentNormalizesVersionWithoutServerFields(t *testing.T) {
cmd := exec.Command(repoFile("scripts", "release_ldflags.sh"),
"agent",
"--version", "6.0.0",
"--update-public-keys", "ZmFrZS11cGRhdGUta2V5",
)
output, err := cmd.CombinedOutput()
@ -52,6 +55,9 @@ func TestReleaseLdflagsAgentNormalizesVersionWithoutServerFields(t *testing.T) {
if !strings.Contains(ldflags, "-X main.Version=v6.0.0") {
t.Fatalf("agent ldflags missing normalized version: %q", ldflags)
}
if !strings.Contains(ldflags, "internal/updatesignature.EmbeddedTrustedPublicKeys=ZmFrZS11cGRhdGUta2V5") {
t.Fatalf("agent ldflags missing update public keys: %q", ldflags)
}
disallowed := []string{
"internal/updates.BuildVersion",
"main.BuildTime",

View file

@ -266,6 +266,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn('git push origin "refs/tags/${TAG}" --force', content)
self.assertIn('Retargeting existing draft tag ${TAG}', content)
self.assertIn('-F target_commitish="${HEAD_SHA}"', content)
self.assertIn("PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}", content)
self.assertIn('gh release upload "${TAG}" release/*.sig --clobber', content)
self.assertIn("Derived rollback command:", helper)
self.assertIn("./scripts/install.sh --version", helper)
self.assertIn("v6 GA date to publish with GA", helper)

View file

@ -4,7 +4,7 @@ set -eu
usage() {
cat >&2 <<'EOF'
usage: release_ldflags.sh <server|agent> --version <version> [--build-time <ts>] [--git-commit <sha>] [--license-public-key <base64>]
usage: release_ldflags.sh <server|agent> --version <version> [--build-time <ts>] [--git-commit <sha>] [--license-public-key <base64>] [--update-public-keys <csv>]
EOF
exit 1
}
@ -20,6 +20,7 @@ version=""
build_time="unknown"
git_commit="unknown"
license_public_key=""
update_public_keys=""
while [ "$#" -gt 0 ]; do
case "$1" in
@ -43,6 +44,11 @@ while [ "$#" -gt 0 ]; do
license_public_key="$2"
shift 2
;;
--update-public-keys)
[ "$#" -ge 2 ] || usage
update_public_keys="$2"
shift 2
;;
*)
usage
;;
@ -74,8 +80,14 @@ case "$mode" in
ldflags="${ldflags} -X github.com/rcourtman/pulse-go-rewrite/pkg/licensing.EmbeddedPublicKey=${license_public_key}"
ldflags="${ldflags} -X github.com/rcourtman/pulse-go-rewrite/internal/license.EmbeddedPublicKey=${license_public_key}"
fi
if [ -n "$update_public_keys" ]; then
ldflags="${ldflags} -X github.com/rcourtman/pulse-go-rewrite/internal/updatesignature.EmbeddedTrustedPublicKeys=${update_public_keys}"
fi
;;
agent)
if [ -n "$update_public_keys" ]; then
ldflags="${ldflags} -X github.com/rcourtman/pulse-go-rewrite/internal/updatesignature.EmbeddedTrustedPublicKeys=${update_public_keys}"
fi
;;
*)
usage

View file

@ -0,0 +1,64 @@
package main
import (
"flag"
"fmt"
"os"
"github.com/rcourtman/pulse-go-rewrite/internal/updatesignature"
)
func usage() {
fmt.Fprintln(os.Stderr, "usage:")
fmt.Fprintln(os.Stderr, " release_update_key.go public-key --private-key <base64-ed25519-key-or-seed>")
fmt.Fprintln(os.Stderr, " release_update_key.go sign --private-key <base64-ed25519-key-or-seed> --file <path>")
os.Exit(1)
}
func main() {
if len(os.Args) < 2 {
usage()
}
switch os.Args[1] {
case "public-key":
publicKeyCmd := flag.NewFlagSet("public-key", flag.ExitOnError)
privateKey := publicKeyCmd.String("private-key", "", "base64-encoded Ed25519 private key or seed")
_ = publicKeyCmd.Parse(os.Args[2:])
key, err := updatesignature.DecodePrivateKey(*privateKey)
if err != nil {
fail(err)
}
encoded, err := updatesignature.PublicKeyString(key)
if err != nil {
fail(err)
}
fmt.Println(encoded)
case "sign":
signCmd := flag.NewFlagSet("sign", flag.ExitOnError)
privateKey := signCmd.String("private-key", "", "base64-encoded Ed25519 private key or seed")
filePath := signCmd.String("file", "", "path to the file to sign")
_ = signCmd.Parse(os.Args[2:])
if *filePath == "" {
usage()
}
key, err := updatesignature.DecodePrivateKey(*privateKey)
if err != nil {
fail(err)
}
signature, err := updatesignature.SignFile(*filePath, key)
if err != nil {
fail(err)
}
fmt.Println(signature)
default:
usage()
}
}
func fail(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}