diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index fee0bcdd1..4c953a4e7 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -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: diff --git a/docs/AGENT_SECURITY.md b/docs/AGENT_SECURITY.md index 112b44faf..bc9ac91c4 100644 --- a/docs/AGENT_SECURITY.md +++ b/docs/AGENT_SECURITY.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 1feed47d3..c1b87343c 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index cf4cd2ad2..47c2c34ea 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index fb57b11ba..76f0b81d4 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index c1eeda344..d9aef481c 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/internal/agentupdate/update.go b/internal/agentupdate/update.go index 56e9d2594..0a70918f9 100644 --- a/internal/agentupdate/update.go +++ b/internal/agentupdate/update.go @@ -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) diff --git a/internal/agentupdate/update_http_test.go b/internal/agentupdate/update_http_test.go index e565ca706..46e580903 100644 --- a/internal/agentupdate/update_http_test.go +++ b/internal/agentupdate/update_http_test.go @@ -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 { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 50a86f0d3..47d9c243b 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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{ diff --git a/internal/api/unified_agent.go b/internal/api/unified_agent.go index 11a77086a..f50bf9eef 100644 --- a/internal/api/unified_agent.go +++ b/internal/api/unified_agent.go @@ -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 diff --git a/internal/api/unified_agent_more_test.go b/internal/api/unified_agent_more_test.go index f75fe485b..5873acc3d 100644 --- a/internal/api/unified_agent_more_test.go +++ b/internal/api/unified_agent_more_test.go @@ -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) { diff --git a/internal/api/unified_agent_test.go b/internal/api/unified_agent_test.go index 3797c2103..e4aee42b4 100644 --- a/internal/api/unified_agent_test.go +++ b/internal/api/unified_agent_test.go @@ -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() diff --git a/internal/dockeragent/self_update.go b/internal/dockeragent/self_update.go index 8f5c48617..d4b6e708a 100644 --- a/internal/dockeragent/self_update.go +++ b/internal/dockeragent/self_update.go @@ -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 diff --git a/internal/dockeragent/self_update_test.go b/internal/dockeragent/self_update_test.go index c8e294635..59a26e09c 100644 --- a/internal/dockeragent/self_update_test.go +++ b/internal/dockeragent/self_update_test.go @@ -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) { diff --git a/internal/dockeragent/signature.go b/internal/dockeragent/signature.go index e6ef258cc..231eeabf8 100644 --- a/internal/dockeragent/signature.go +++ b/internal/dockeragent/signature.go @@ -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) } diff --git a/internal/dockeragent/signature_test.go b/internal/dockeragent/signature_test.go index a6e24984c..e1cba560b 100644 --- a/internal/dockeragent/signature_test.go +++ b/internal/dockeragent/signature_test.go @@ -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") diff --git a/internal/updatesignature/signature.go b/internal/updatesignature/signature.go new file mode 100644 index 000000000..47f4f697b --- /dev/null +++ b/internal/updatesignature/signature.go @@ -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 +} diff --git a/internal/updatesignature/signature_test.go b/internal/updatesignature/signature_test.go new file mode 100644 index 000000000..46ac9228d --- /dev/null +++ b/internal/updatesignature/signature_test.go @@ -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)) + } +} diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 7bf200474..988e44491 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -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}..." diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index ae8fb3ab6..b8e7612e2 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -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}"`, diff --git a/scripts/installtests/release_ldflags_test.go b/scripts/installtests/release_ldflags_test.go index 7964ab1a1..db4567c17 100644 --- a/scripts/installtests/release_ldflags_test.go +++ b/scripts/installtests/release_ldflags_test.go @@ -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", diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index e05c764c7..f75a81b57 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -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) diff --git a/scripts/release_ldflags.sh b/scripts/release_ldflags.sh index c19763192..af61ccdf6 100755 --- a/scripts/release_ldflags.sh +++ b/scripts/release_ldflags.sh @@ -4,7 +4,7 @@ set -eu usage() { cat >&2 <<'EOF' -usage: release_ldflags.sh --version [--build-time ] [--git-commit ] [--license-public-key ] +usage: release_ldflags.sh --version [--build-time ] [--git-commit ] [--license-public-key ] [--update-public-keys ] 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 diff --git a/scripts/release_update_key.go b/scripts/release_update_key.go new file mode 100644 index 000000000..1d92c38b2 --- /dev/null +++ b/scripts/release_update_key.go @@ -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 ") + fmt.Fprintln(os.Stderr, " release_update_key.go sign --private-key --file ") + 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) +}