mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Fail closed when installing MCP binaries
Verify the signed release checksum manifest against Pulse's pinned SSH key before either MCP installer accepts a downloaded binary. Remove the unsigned bypass and require one exact digest entry. Include bare Unix MCP executables in release checksum/signature assembly, cover unavailable, invalid, ambiguous, mismatched, and successful evidence paths with executable regression tests, and enforce MCP installer pins against the configured release key.
This commit is contained in:
@@ -799,12 +799,18 @@ jobs:
|
||||
--public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \
|
||||
--comment pulse-installer
|
||||
)"
|
||||
for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh; do
|
||||
for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh scripts/install-mcp.sh release/install-mcp.sh; do
|
||||
grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" "${installer}" >/dev/null || {
|
||||
echo "::error::${installer} does not trust the configured release signing key."
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
for installer in scripts/install-mcp.ps1 release/install-mcp.ps1; do
|
||||
grep -F "\$PinnedReleaseSshPublicKey = '${TRUSTED_SSH_PUBLIC_KEY}'" "${installer}" >/dev/null || {
|
||||
echo "::error::${installer} does not trust the configured release signing key."
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
- name: Validate complete candidate locally
|
||||
env:
|
||||
|
||||
@@ -71,8 +71,10 @@ outcome without polling the audit endpoint.
|
||||
notifications on the stdio channel, which lets an autonomous MCP-bound agent
|
||||
react to push events without holding a separate HTTP connection. The
|
||||
one-line installers `install-mcp.sh` and `install-mcp.ps1` fetch the
|
||||
matching binary from the latest Pulse release and verify its checksum.
|
||||
Building from source stays available.
|
||||
matching binary from the latest Pulse release, verify the checksum manifest
|
||||
against Pulse's pinned release key, and then verify the binary's checksum.
|
||||
They refuse installation when any integrity evidence is unavailable or
|
||||
invalid. Building from source stays available.
|
||||
|
||||
- **`cmd/agent-probe`** is a small Go binary that walks the discovery,
|
||||
triage, depth, and push flow against a running instance. Use it as a smoke
|
||||
|
||||
@@ -3301,7 +3301,9 @@ used by Pulse Assistant to OpenCode, Claude Desktop, Claude Code, or
|
||||
other MCP-speaking clients. The installers fetch a published
|
||||
`pulse-mcp-<os>-<arch>` binary from the latest GitHub Release,
|
||||
verify SHA256 against the same `checksums.txt` the rest of the
|
||||
release uses, and place the binary at `~/.local/bin/pulse-mcp`
|
||||
release uses, first verifying `checksums.txt.sshsig` against the pinned
|
||||
Pulse release key and failing closed if either integrity artifact is missing,
|
||||
invalid, or ambiguous, and place the binary at `~/.local/bin/pulse-mcp`
|
||||
(Unix) or `$LOCALAPPDATA\pulse-mcp\pulse-mcp.exe` (Windows). The
|
||||
binary takes no version ldflags because it reads the manifest
|
||||
from the Pulse instance it points at. `scripts/build-release.sh`
|
||||
@@ -3316,7 +3318,7 @@ and `.../install-mcp.sh` are stable redirect targets the
|
||||
installers consume. macOS notarization is intentionally skipped
|
||||
for v1: the README documents the Gatekeeper bypass and the
|
||||
install-script flow downloads the same unsigned binary, with the
|
||||
audit trail of SHA256 verification preserved.
|
||||
audit trail of signed-manifest and SHA256 verification preserved.
|
||||
The adapter's complete request/response tool-list projection, manifest
|
||||
projection, capability and governance metadata formatting, request/response
|
||||
tool filtering, typed input-schema projection, and API route/body call
|
||||
|
||||
@@ -71,8 +71,10 @@ outcome without polling the audit endpoint.
|
||||
notifications on the stdio channel, which lets an autonomous MCP-bound agent
|
||||
react to push events without holding a separate HTTP connection. The
|
||||
one-line installers `install-mcp.sh` and `install-mcp.ps1` fetch the
|
||||
matching binary from the latest Pulse release and verify its checksum.
|
||||
Building from source stays available.
|
||||
matching binary from the latest Pulse release, verify the checksum manifest
|
||||
against Pulse's pinned release key, and then verify the binary's checksum.
|
||||
They refuse installation when any integrity evidence is unavailable or
|
||||
invalid. Building from source stays available.
|
||||
|
||||
- **`cmd/agent-probe`** is a small Go binary that walks the discovery,
|
||||
triage, depth, and push flow against a running instance. Use it as a smoke
|
||||
|
||||
+75
-28
@@ -1,8 +1,8 @@
|
||||
# Pulse MCP Server Adapter Installer (Windows)
|
||||
#
|
||||
# Detects the local architecture, downloads the matching pulse-mcp.exe
|
||||
# from the latest GitHub Release, verifies SHA256 against the published
|
||||
# checksums file, and places the binary on PATH.
|
||||
# from the latest GitHub Release, verifies the signed checksum manifest against
|
||||
# Pulse's pinned release key, verifies SHA256, and places the binary on PATH.
|
||||
#
|
||||
# Usage:
|
||||
# irm https://github.com/rcourtman/Pulse/releases/latest/download/install-mcp.ps1 | iex
|
||||
@@ -11,7 +11,6 @@
|
||||
# PULSE_MCP_VERSION Override the version to install. Default: latest.
|
||||
# PULSE_MCP_BIN_DIR Where to install. Default: $env:LOCALAPPDATA\pulse-mcp.
|
||||
# PULSE_MCP_REPO GitHub repo. Default: rcourtman/Pulse.
|
||||
# PULSE_MCP_NO_VERIFY If "1", skip SHA256 verification (not recommended).
|
||||
#
|
||||
# After install, configure your MCP client per `cmd/pulse-mcp/README.md`
|
||||
# in the Pulse repository (or your installed Pulse server's `docs/AGENT_SUBSTRATE.md`).
|
||||
@@ -19,8 +18,7 @@
|
||||
param (
|
||||
[string]$Version = $env:PULSE_MCP_VERSION,
|
||||
[string]$BinDir = $env:PULSE_MCP_BIN_DIR,
|
||||
[string]$Repo = $env:PULSE_MCP_REPO,
|
||||
[switch]$NoVerify
|
||||
[string]$Repo = $env:PULSE_MCP_REPO
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -28,7 +26,9 @@ $ErrorActionPreference = 'Stop'
|
||||
if (-not $Version) { $Version = 'latest' }
|
||||
if (-not $Repo) { $Repo = 'rcourtman/Pulse' }
|
||||
if (-not $BinDir) { $BinDir = Join-Path $env:LOCALAPPDATA 'pulse-mcp' }
|
||||
if ($env:PULSE_MCP_NO_VERIFY -eq '1') { $NoVerify = $true }
|
||||
$SignatureIdentity = 'pulse-installer'
|
||||
$SignatureNamespace = 'pulse-install'
|
||||
$PinnedReleaseSshPublicKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer'
|
||||
|
||||
function Write-Log($message) {
|
||||
Write-Host "[install-mcp] $message"
|
||||
@@ -53,22 +53,73 @@ function Resolve-ReleaseBase {
|
||||
return "https://github.com/$Repo/releases/download/$Version"
|
||||
}
|
||||
|
||||
function Get-RemoteChecksum($base, $binaryName) {
|
||||
$checksumsUrl = "$base/checksums.txt"
|
||||
function Get-SshKeygenPath {
|
||||
$command = Get-Command ssh-keygen.exe -ErrorAction SilentlyContinue
|
||||
if ($null -eq $command) {
|
||||
$command = Get-Command ssh-keygen -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($null -eq $command) {
|
||||
throw 'ssh-keygen is required to verify signed Pulse downloads; refusing unverified install'
|
||||
}
|
||||
return $command.Source
|
||||
}
|
||||
|
||||
function Assert-ChecksumManifestSignature($manifestPath, $signaturePath) {
|
||||
$allowedSignersPath = [System.IO.Path]::GetTempFileName()
|
||||
$stdoutPath = [System.IO.Path]::GetTempFileName()
|
||||
$stderrPath = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $checksumsUrl -UseBasicParsing -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Log "warning: could not fetch checksums.txt; skipping verification"
|
||||
return $null
|
||||
}
|
||||
foreach ($line in $response.Content -split "`n") {
|
||||
$parts = $line.Trim() -split '\s+', 2
|
||||
if ($parts.Length -eq 2 -and $parts[1] -eq $binaryName) {
|
||||
return $parts[0]
|
||||
[System.IO.File]::WriteAllText($allowedSignersPath, "$SignatureIdentity $PinnedReleaseSshPublicKey`n")
|
||||
$sshKeygen = Get-SshKeygenPath
|
||||
$commandLine = "`"$sshKeygen`" -Y verify -f `"$allowedSignersPath`" -I `"$SignatureIdentity`" -n `"$SignatureNamespace`" -s `"$signaturePath`" < `"$manifestPath`""
|
||||
$process = Start-Process -FilePath 'cmd.exe' `
|
||||
-ArgumentList '/d', '/s', '/c', $commandLine `
|
||||
-NoNewWindow `
|
||||
-Wait `
|
||||
-PassThru `
|
||||
-RedirectStandardOutput $stdoutPath `
|
||||
-RedirectStandardError $stderrPath
|
||||
if ($process.ExitCode -ne 0) {
|
||||
throw 'cryptographic signature verification failed for checksums.txt'
|
||||
}
|
||||
} finally {
|
||||
Remove-Item -Force $allowedSignersPath, $stdoutPath, $stderrPath -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Get-VerifiedRemoteChecksum($base, $binaryName) {
|
||||
$checksumsUrl = "$base/checksums.txt"
|
||||
$signatureUrl = "$checksumsUrl.sshsig"
|
||||
$manifestPath = [System.IO.Path]::GetTempFileName()
|
||||
$signaturePath = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
try {
|
||||
Invoke-WebRequest -Uri $checksumsUrl -UseBasicParsing -OutFile $manifestPath -ErrorAction Stop
|
||||
} catch {
|
||||
throw 'could not fetch checksums.txt; refusing unverified install'
|
||||
}
|
||||
try {
|
||||
Invoke-WebRequest -Uri $signatureUrl -UseBasicParsing -OutFile $signaturePath -ErrorAction Stop
|
||||
} catch {
|
||||
throw 'could not fetch checksums.txt.sshsig; refusing unverified install'
|
||||
}
|
||||
Assert-ChecksumManifestSignature $manifestPath $signaturePath
|
||||
Write-Log 'release signature verified'
|
||||
|
||||
$matches = @()
|
||||
foreach ($line in [System.IO.File]::ReadAllLines($manifestPath)) {
|
||||
$parts = $line.Trim() -split '\s+', 2
|
||||
if ($parts.Length -eq 2 -and $parts[1] -ceq $binaryName) {
|
||||
$matches += $parts[0]
|
||||
}
|
||||
}
|
||||
if ($matches.Count -ne 1 -or $matches[0] -notmatch '^[0-9a-fA-F]{64}$') {
|
||||
throw "checksums.txt must contain exactly one valid SHA256 entry for $binaryName"
|
||||
}
|
||||
return $matches[0].ToLowerInvariant()
|
||||
} finally {
|
||||
Remove-Item -Force $manifestPath, $signaturePath -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Log "warning: $binaryName not listed in checksums.txt; skipping verification"
|
||||
return $null
|
||||
}
|
||||
|
||||
function Main {
|
||||
@@ -94,16 +145,12 @@ function Main {
|
||||
throw "download failed: $url`nIf a release exists for this version, the binary may not yet be published for $platform.`nBuild from source: go install github.com/rcourtman/pulse-go-rewrite/cmd/pulse-mcp@latest"
|
||||
}
|
||||
|
||||
if (-not $NoVerify) {
|
||||
$expected = Get-RemoteChecksum $base $binaryName
|
||||
if ($expected) {
|
||||
$actual = (Get-FileHash -Path $tmp -Algorithm SHA256).Hash.ToLower()
|
||||
if ($actual -ne $expected.ToLower()) {
|
||||
throw "sha256 mismatch for ${binaryName}: expected $expected, got $actual"
|
||||
}
|
||||
Write-Log 'sha256 verified'
|
||||
}
|
||||
$expected = Get-VerifiedRemoteChecksum $base $binaryName
|
||||
$actual = (Get-FileHash -Path $tmp -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $expected) {
|
||||
throw "sha256 mismatch for ${binaryName}: expected $expected, got $actual"
|
||||
}
|
||||
Write-Log 'sha256 verified'
|
||||
|
||||
$dest = Join-Path $BinDir 'pulse-mcp.exe'
|
||||
Move-Item -Path $tmp -Destination $dest -Force
|
||||
|
||||
+55
-33
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# Detects the local platform/architecture, downloads the matching
|
||||
# pulse-mcp binary from the latest GitHub Release, verifies the
|
||||
# SHA256 checksum against the published checksums file, and places
|
||||
# the binary on PATH.
|
||||
# signed checksum manifest against Pulse's pinned release key, verifies the
|
||||
# binary's SHA256 digest, and places the binary on PATH.
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install-mcp.sh | bash
|
||||
@@ -16,7 +16,6 @@
|
||||
# PULSE_MCP_BIN_DIR Where to install the binary.
|
||||
# Default: $HOME/.local/bin if writable, else /usr/local/bin.
|
||||
# PULSE_MCP_REPO GitHub repo to download from. Default: rcourtman/Pulse.
|
||||
# PULSE_MCP_NO_VERIFY If "1", skip SHA256 verification (not recommended).
|
||||
#
|
||||
# After install, configure your MCP client per `cmd/pulse-mcp/README.md` in the
|
||||
# Pulse repository (or `docs/AGENT_SUBSTRATE.md` in your installed Pulse server).
|
||||
@@ -28,7 +27,13 @@ set -euo pipefail
|
||||
|
||||
REPO="${PULSE_MCP_REPO:-rcourtman/Pulse}"
|
||||
VERSION="${PULSE_MCP_VERSION:-latest}"
|
||||
NO_VERIFY="${PULSE_MCP_NO_VERIFY:-}"
|
||||
SIGNATURE_IDENTITY="pulse-installer"
|
||||
SIGNATURE_NAMESPACE="pulse-install"
|
||||
PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer"
|
||||
tmp=""
|
||||
checksums_tmp=""
|
||||
signature_tmp=""
|
||||
allowed_signers=""
|
||||
|
||||
log() {
|
||||
printf '[install-mcp] %s\n' "$*"
|
||||
@@ -115,8 +120,10 @@ main() {
|
||||
require_cmd curl
|
||||
require_cmd uname
|
||||
require_cmd install
|
||||
require_cmd ssh-keygen
|
||||
|
||||
local platform install_dir base bin_name url tmp checksums_url checksums_tmp
|
||||
local platform install_dir base bin_name url checksums_url
|
||||
local signature_url expected actual matches
|
||||
platform="$(detect_platform)"
|
||||
install_dir="$(choose_install_dir)"
|
||||
base="$(resolve_release_base)"
|
||||
@@ -136,36 +143,51 @@ If a release exists for this version, the binary may not yet be published for ${
|
||||
Build from source: go install github.com/rcourtman/pulse-go-rewrite/cmd/pulse-mcp@latest"
|
||||
fi
|
||||
|
||||
if [ "${NO_VERIFY}" != "1" ]; then
|
||||
local sha_cmd
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha_cmd="sha256sum"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
sha_cmd="shasum -a 256"
|
||||
else
|
||||
err "no sha256 tool found (sha256sum or shasum). Set PULSE_MCP_NO_VERIFY=1 to skip verification."
|
||||
fi
|
||||
|
||||
checksums_url="${base}/checksums.txt"
|
||||
checksums_tmp="$(mktemp -t pulse-mcp-sums.XXXXXX)"
|
||||
trap 'rm -f "${tmp}" "${checksums_tmp}"' EXIT
|
||||
if ! curl -fsSL --retry 3 "${checksums_url}" -o "${checksums_tmp}"; then
|
||||
log "warning: could not fetch checksums.txt; skipping verification"
|
||||
else
|
||||
local expected actual
|
||||
expected="$(awk -v name="${bin_name}" '$2 == name {print $1; exit}' "${checksums_tmp}" || true)"
|
||||
if [ -z "${expected}" ]; then
|
||||
log "warning: ${bin_name} not listed in checksums.txt; skipping verification"
|
||||
else
|
||||
actual="$(${sha_cmd} "${tmp}" | awk '{print $1}')"
|
||||
if [ "${expected}" != "${actual}" ]; then
|
||||
err "sha256 mismatch for ${bin_name}: expected ${expected}, got ${actual}"
|
||||
fi
|
||||
log "sha256 verified"
|
||||
fi
|
||||
fi
|
||||
local sha_cmd
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha_cmd="sha256sum"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
sha_cmd="shasum -a 256"
|
||||
else
|
||||
err "no sha256 tool found (sha256sum or shasum); refusing unverified install"
|
||||
fi
|
||||
|
||||
checksums_url="${base}/checksums.txt"
|
||||
signature_url="${checksums_url}.sshsig"
|
||||
checksums_tmp="$(mktemp -t pulse-mcp-sums.XXXXXX)"
|
||||
signature_tmp="$(mktemp -t pulse-mcp-signature.XXXXXX)"
|
||||
allowed_signers="$(mktemp -t pulse-mcp-signers.XXXXXX)"
|
||||
trap 'rm -f "${tmp}" "${checksums_tmp}" "${signature_tmp}" "${allowed_signers}"' EXIT
|
||||
|
||||
if ! curl -fsSL --retry 3 "${checksums_url}" -o "${checksums_tmp}"; then
|
||||
err "could not fetch checksums.txt; refusing unverified install"
|
||||
fi
|
||||
if ! curl -fsSL --retry 3 "${signature_url}" -o "${signature_tmp}"; then
|
||||
err "could not fetch checksums.txt.sshsig; refusing unverified install"
|
||||
fi
|
||||
|
||||
printf '%s %s\n' "${SIGNATURE_IDENTITY}" "${PINNED_RELEASE_SSH_PUBLIC_KEY}" > "${allowed_signers}"
|
||||
if ! ssh-keygen -Y verify \
|
||||
-f "${allowed_signers}" \
|
||||
-I "${SIGNATURE_IDENTITY}" \
|
||||
-n "${SIGNATURE_NAMESPACE}" \
|
||||
-s "${signature_tmp}" < "${checksums_tmp}" >/dev/null 2>&1; then
|
||||
err "cryptographic signature verification failed for checksums.txt"
|
||||
fi
|
||||
log "release signature verified"
|
||||
|
||||
matches="$(awk -v name="${bin_name}" '$2 == name && NF == 2 {count++; checksum=$1} END {if (count == 1) print checksum; else exit 1}' "${checksums_tmp}" || true)"
|
||||
expected="$(printf '%s' "${matches}" | tr '[:upper:]' '[:lower:]')"
|
||||
if ! printf '%s' "${expected}" | grep -Eq '^[0-9a-f]{64}$'; then
|
||||
err "checksums.txt must contain exactly one valid SHA256 entry for ${bin_name}"
|
||||
fi
|
||||
|
||||
actual="$(${sha_cmd} "${tmp}" | awk '{print tolower($1)}')"
|
||||
if [ "${expected}" != "${actual}" ]; then
|
||||
err "sha256 mismatch for ${bin_name}: expected ${expected}, got ${actual}"
|
||||
fi
|
||||
log "sha256 verified"
|
||||
|
||||
install -m 0755 "${tmp}" "${install_dir}/pulse-mcp"
|
||||
log "installed: ${install_dir}/pulse-mcp"
|
||||
|
||||
|
||||
@@ -2512,8 +2512,10 @@ func TestBuildReleasePackagesPulseMcpForAllPlatforms(t *testing.T) {
|
||||
for _, needle := range []string{
|
||||
`detect_platform()`,
|
||||
`choose_install_dir()`,
|
||||
`PULSE_MCP_NO_VERIFY`,
|
||||
`PINNED_RELEASE_SSH_PUBLIC_KEY`,
|
||||
`checksums.txt`,
|
||||
`checksums.txt.sshsig`,
|
||||
`ssh-keygen -Y verify`,
|
||||
`sha256 mismatch`,
|
||||
} {
|
||||
if !strings.Contains(string(mcpScript), needle) {
|
||||
@@ -2521,14 +2523,54 @@ func TestBuildReleasePackagesPulseMcpForAllPlatforms(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Unix installers consume bare binaries, not the versioned archives. Keep
|
||||
// those exact assets in the signed manifest; the broad pulse-*.tar.gz and
|
||||
// pulse-*.exe patterns otherwise leave only Unix bare MCP binaries out.
|
||||
commonContent, err := os.ReadFile(repoFile("scripts", "release_asset_common.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("read release_asset_common.sh: %v", err)
|
||||
}
|
||||
for _, needle := range []string{
|
||||
`checksum_files+=( pulse-mcp-linux-* )`,
|
||||
`checksum_files+=( pulse-mcp-darwin-* )`,
|
||||
`checksum_files+=( pulse-mcp-freebsd-* )`,
|
||||
} {
|
||||
if !strings.Contains(string(commonContent), needle) {
|
||||
t.Fatalf("release checksum collection missing bare MCP assets: %s", needle)
|
||||
}
|
||||
}
|
||||
|
||||
releaseDir := t.TempDir()
|
||||
for _, asset := range []string{
|
||||
"pulse-mcp-linux-amd64",
|
||||
"pulse-mcp-darwin-arm64",
|
||||
"pulse-mcp-freebsd-amd64",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(releaseDir, asset), []byte(asset), 0o755); err != nil {
|
||||
t.Fatalf("write MCP checksum fixture: %v", err)
|
||||
}
|
||||
}
|
||||
checksumCmd := exec.Command("bash", "-c", `source "$1"; pulse_release_collect_checksum_files "$2"`, "pulse-mcp-checksum-test", repoFile("scripts", "release_asset_common.sh"), releaseDir)
|
||||
checksumOutput, err := checksumCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("collect MCP release checksum files: %v\n%s", err, checksumOutput)
|
||||
}
|
||||
for _, asset := range []string{"pulse-mcp-linux-amd64", "pulse-mcp-darwin-arm64", "pulse-mcp-freebsd-amd64"} {
|
||||
if !strings.Contains(string(checksumOutput), asset) {
|
||||
t.Fatalf("bare MCP release asset %s missing from checksum/signature input:\n%s", asset, checksumOutput)
|
||||
}
|
||||
}
|
||||
|
||||
mcpPowerShell, err := os.ReadFile(repoFile("scripts", "install-mcp.ps1"))
|
||||
if err != nil {
|
||||
t.Fatalf("read install-mcp.ps1: %v", err)
|
||||
}
|
||||
for _, needle := range []string{
|
||||
`function Resolve-Architecture`,
|
||||
`PULSE_MCP_NO_VERIFY`,
|
||||
`$PinnedReleaseSshPublicKey`,
|
||||
`checksums.txt`,
|
||||
`checksums.txt.sshsig`,
|
||||
`Assert-ChecksumManifestSignature`,
|
||||
`Get-FileHash -Path $tmp -Algorithm SHA256`,
|
||||
`sha256 mismatch`,
|
||||
} {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package installtests
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const productionMCPReleaseKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer"
|
||||
|
||||
func TestInstallMCPRequiresSignedChecksumEvidence(t *testing.T) {
|
||||
for _, command := range []string{"bash", "ssh-keygen"} {
|
||||
if _, err := exec.LookPath(command); err != nil {
|
||||
t.Skipf("%s not installed", command)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
manifest func(binary []byte) string
|
||||
invalidSig bool
|
||||
missingAsset string
|
||||
wantSuccess bool
|
||||
wantOutput string
|
||||
}{
|
||||
{
|
||||
name: "valid signed manifest",
|
||||
manifest: func(binary []byte) string {
|
||||
digest := sha256.Sum256(binary)
|
||||
return fmt.Sprintf("%x pulse-mcp-linux-amd64\n", digest)
|
||||
},
|
||||
wantSuccess: true,
|
||||
wantOutput: "release signature verified",
|
||||
},
|
||||
{
|
||||
name: "manifest unavailable",
|
||||
manifest: func(binary []byte) string {
|
||||
digest := sha256.Sum256(binary)
|
||||
return fmt.Sprintf("%x pulse-mcp-linux-amd64\n", digest)
|
||||
},
|
||||
missingAsset: "checksums.txt",
|
||||
wantOutput: "could not fetch checksums.txt; refusing unverified install",
|
||||
},
|
||||
{
|
||||
name: "signature unavailable",
|
||||
manifest: func(binary []byte) string {
|
||||
digest := sha256.Sum256(binary)
|
||||
return fmt.Sprintf("%x pulse-mcp-linux-amd64\n", digest)
|
||||
},
|
||||
missingAsset: "checksums.txt.sshsig",
|
||||
wantOutput: "could not fetch checksums.txt.sshsig; refusing unverified install",
|
||||
},
|
||||
{
|
||||
name: "signature invalid",
|
||||
manifest: func(binary []byte) string {
|
||||
digest := sha256.Sum256(binary)
|
||||
return fmt.Sprintf("%x pulse-mcp-linux-amd64\n", digest)
|
||||
},
|
||||
invalidSig: true,
|
||||
wantOutput: "cryptographic signature verification failed for checksums.txt",
|
||||
},
|
||||
{
|
||||
name: "binary omitted",
|
||||
manifest: func(binary []byte) string {
|
||||
digest := sha256.Sum256(binary)
|
||||
return fmt.Sprintf("%x another-file\n", digest)
|
||||
},
|
||||
wantOutput: "checksums.txt must contain exactly one valid SHA256 entry",
|
||||
},
|
||||
{
|
||||
name: "binary duplicated",
|
||||
manifest: func(binary []byte) string {
|
||||
digest := sha256.Sum256(binary)
|
||||
return fmt.Sprintf("%x pulse-mcp-linux-amd64\n%x pulse-mcp-linux-amd64\n", digest, digest)
|
||||
},
|
||||
wantOutput: "checksums.txt must contain exactly one valid SHA256 entry",
|
||||
},
|
||||
{
|
||||
name: "digest mismatch",
|
||||
manifest: func(_ []byte) string {
|
||||
return strings.Repeat("0", 64) + " pulse-mcp-linux-amd64\n"
|
||||
},
|
||||
wantOutput: "sha256 mismatch for pulse-mcp-linux-amd64",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
fixtureDir := filepath.Join(tmp, "fixtures")
|
||||
binDir := filepath.Join(tmp, "bin")
|
||||
installDir := filepath.Join(tmp, "install")
|
||||
for _, dir := range []string{fixtureDir, binDir, installDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
binary := []byte("test pulse-mcp executable\n")
|
||||
writeTestFile(t, filepath.Join(fixtureDir, "pulse-mcp-linux-amd64"), binary, 0o644)
|
||||
manifestPath := filepath.Join(fixtureDir, "checksums.txt")
|
||||
writeTestFile(t, manifestPath, []byte(tt.manifest(binary)), 0o644)
|
||||
|
||||
privateKeyPath := filepath.Join(tmp, "release-key")
|
||||
runTestCommand(t, exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "pulse-installer", "-f", privateKeyPath))
|
||||
publicKeyBytes, err := os.ReadFile(privateKeyPath + ".pub")
|
||||
if err != nil {
|
||||
t.Fatalf("read test public key: %v", err)
|
||||
}
|
||||
publicKey := strings.TrimSpace(string(publicKeyBytes))
|
||||
|
||||
sign := exec.Command("ssh-keygen", "-q", "-Y", "sign", "-f", privateKeyPath, "-n", "pulse-install", manifestPath)
|
||||
runTestCommand(t, sign)
|
||||
if err := os.Rename(manifestPath+".sig", filepath.Join(fixtureDir, "checksums.txt.sshsig")); err != nil {
|
||||
t.Fatalf("rename signature: %v", err)
|
||||
}
|
||||
if tt.invalidSig {
|
||||
writeTestFile(t, filepath.Join(fixtureDir, "checksums.txt.sshsig"), []byte("not a signature\n"), 0o644)
|
||||
}
|
||||
|
||||
scriptBytes, err := os.ReadFile(repoFile("scripts", "install-mcp.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("read installer: %v", err)
|
||||
}
|
||||
script := strings.Replace(string(scriptBytes), productionMCPReleaseKey, publicKey, 1)
|
||||
if script == string(scriptBytes) {
|
||||
t.Fatal("installer no longer contains the expected pinned production key")
|
||||
}
|
||||
scriptPath := filepath.Join(tmp, "install-mcp.sh")
|
||||
writeTestFile(t, scriptPath, []byte(script), 0o755)
|
||||
|
||||
curlStub := `#!/bin/sh
|
||||
set -eu
|
||||
output=''
|
||||
url=''
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-o) output="$2"; shift 2 ;;
|
||||
http*) url="$1"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
name="${url##*/}"
|
||||
if [ -n "${MISSING_ASSET:-}" ] && [ "$name" = "$MISSING_ASSET" ]; then
|
||||
exit 22
|
||||
fi
|
||||
cp "$FIXTURE_DIR/$name" "$output"
|
||||
`
|
||||
writeTestFile(t, filepath.Join(binDir, "curl"), []byte(curlStub), 0o755)
|
||||
|
||||
cmd := exec.Command("bash", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
|
||||
"HOME="+tmp,
|
||||
"FIXTURE_DIR="+fixtureDir,
|
||||
"MISSING_ASSET="+tt.missingAsset,
|
||||
"PULSE_MCP_BIN_DIR="+installDir,
|
||||
"PULSE_MCP_VERSION=v-test",
|
||||
)
|
||||
output, runErr := cmd.CombinedOutput()
|
||||
if tt.wantSuccess && runErr != nil {
|
||||
t.Fatalf("installer failed: %v\n%s", runErr, output)
|
||||
}
|
||||
if !tt.wantSuccess && runErr == nil {
|
||||
t.Fatalf("installer unexpectedly succeeded:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(string(output), tt.wantOutput) {
|
||||
t.Fatalf("installer output missing %q:\n%s", tt.wantOutput, output)
|
||||
}
|
||||
|
||||
installedPath := filepath.Join(installDir, "pulse-mcp")
|
||||
_, statErr := os.Stat(installedPath)
|
||||
if tt.wantSuccess && statErr != nil {
|
||||
t.Fatalf("verified binary was not installed: %v", statErr)
|
||||
}
|
||||
if !tt.wantSuccess && !os.IsNotExist(statErr) {
|
||||
t.Fatalf("unverified binary reached install destination: stat err=%v", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallMCPPowerShellFailsClosed(t *testing.T) {
|
||||
path := repoFile("scripts", "install-mcp.ps1")
|
||||
assertFileContainsAll(t, path,
|
||||
"$PinnedReleaseSshPublicKey = 'ssh-ed25519 ",
|
||||
"Get-SshKeygenPath",
|
||||
"Assert-ChecksumManifestSignature $manifestPath $signaturePath",
|
||||
"could not fetch checksums.txt; refusing unverified install",
|
||||
"could not fetch checksums.txt.sshsig; refusing unverified install",
|
||||
"checksums.txt must contain exactly one valid SHA256 entry",
|
||||
"sha256 mismatch for ${binaryName}",
|
||||
)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read PowerShell installer: %v", err)
|
||||
}
|
||||
for _, insecure := range []string{"PULSE_MCP_NO_VERIFY", "NoVerify", "skipping verification"} {
|
||||
if strings.Contains(string(content), insecure) {
|
||||
t.Fatalf("PowerShell installer retains fail-open control %q", insecure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, path string, content []byte, mode os.FileMode) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, content, mode); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func runTestCommand(t *testing.T, cmd *exec.Cmd) {
|
||||
t.Helper()
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%s failed: %v\n%s", strings.Join(cmd.Args, " "), err, output)
|
||||
}
|
||||
}
|
||||
@@ -257,6 +257,15 @@ pulse_release_collect_checksum_files() {
|
||||
if compgen -G "pulse-agent-freebsd-*" > /dev/null; then
|
||||
checksum_files+=( pulse-agent-freebsd-* )
|
||||
fi
|
||||
if compgen -G "pulse-mcp-linux-*" > /dev/null; then
|
||||
checksum_files+=( pulse-mcp-linux-* )
|
||||
fi
|
||||
if compgen -G "pulse-mcp-darwin-*" > /dev/null; then
|
||||
checksum_files+=( pulse-mcp-darwin-* )
|
||||
fi
|
||||
if compgen -G "pulse-mcp-freebsd-*" > /dev/null; then
|
||||
checksum_files+=( pulse-mcp-freebsd-* )
|
||||
fi
|
||||
if compgen -G "pulse-*.exe" > /dev/null; then
|
||||
checksum_files+=( pulse-*.exe )
|
||||
fi
|
||||
|
||||
@@ -1632,6 +1632,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
||||
self.assertNotIn('tar -xOf "$tarball" "$entry"', release_validator)
|
||||
self.assertIn("go run ./scripts/release_update_key.go public-key-ssh", candidate_workflow)
|
||||
self.assertIn("does not trust the configured release signing key", candidate_workflow)
|
||||
self.assertIn("scripts/install-mcp.sh release/install-mcp.sh", candidate_workflow)
|
||||
self.assertIn("scripts/install-mcp.ps1 release/install-mcp.ps1", candidate_workflow)
|
||||
self.assertIn("$PinnedReleaseSshPublicKey = '${TRUSTED_SSH_PUBLIC_KEY}'", candidate_workflow)
|
||||
self.assertIn("TRUSTED_SSH_PUBLIC_KEY", update_demo_workflow)
|
||||
self.assertIn('sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\\"${TRUSTED_SSH_PUBLIC_KEY}\\"|" /tmp/pulse-install.sh', update_demo_workflow)
|
||||
self.assertIn("bash .github/scripts/setup-demo-ssh.sh", update_demo_workflow)
|
||||
|
||||
Reference in New Issue
Block a user