mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Require signed unified agent release assets
This commit is contained in:
parent
7b1520b760
commit
7be844f23a
24 changed files with 976 additions and 115 deletions
4
.github/workflows/create-release.yml
vendored
4
.github/workflows/create-release.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
174
internal/updatesignature/signature.go
Normal file
174
internal/updatesignature/signature.go
Normal 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
|
||||
}
|
||||
130
internal/updatesignature/signature_test.go
Normal file
130
internal/updatesignature/signature_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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}..."
|
||||
|
|
|
|||
|
|
@ -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}"`,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
64
scripts/release_update_key.go
Normal file
64
scripts/release_update_key.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue