Skip to content

Commit 7aefeaf

Browse files
authored
Stage the PowerShell installer as ASCII+CRLF before signing it (#597)
v0.8.0-rc.2 published a basecamp_installer.ps1 that PowerShell rejects: Installer status (pwsh): HashMismatch - the hash of the file does not match the hash stored in the digital signature. Under AllSigned or RemoteSigned the installer will not run at all, and a signature that looks tampered-with is worse than none: it reads as a supply-chain compromise rather than an unsigned file. Only the .ps1 is affected. Both .exe signatures verified Valid on both arches, so DigiCert KeyLocker and jsign are fine for PE. The arch asymmetry in the failure was an illusion -- all three installer verification steps are `if: matrix.arch == 'amd64'`, so arm64 never checked it. Two independent causes, both proven rather than assumed. Line endings. jsign digests a script as UTF-16LE of the content with the signature block stripped and applies NO line-ending normalization (SignableScript.computeDigest); PowerShell's script SIP normalizes to CRLF first. Confirmed by extracting the SpcIndirectData digest from the published asset and reproducing it exactly: sha256(utf-16-le(content-as-is)) == eb73e59e...5c7487, the value jsign embedded. The CRLF-normalized digest differs. Encoding, which a CRLF-only fix would not have caught. The file carries no BOM. jsign assumes UTF-8; Windows PowerShell 5.1 assumes ANSI (Windows-1252) for BOM-less files. install.ps1 contained five em-dashes, so the two sides decoded different characters and would still have disagreed on the 5.1 verification step. Within ASCII the encodings are byte-identical, so the question disappears -- the em-dashes are now `--`, and staging rejects any future non-ASCII rather than signing something unverifiable. Adding a BOM was the other way out. It was rejected because it trades a known-good invariant for jsign's isByteOrderMarkSigned() matching Microsoft's undocumented treatment of the BOM in the hash, which cannot be tested without a signing round-trip. The source file is untouched in shape: raw-main install.ps1 stays LF and unsigned, which is the `irm | iex` path and needs neither. Verified locally without signing credentials by reproducing both digests: on the staged file they are now identical (aa030635...), and that value is exactly what PowerShell was computing all along. e2e/installer_signing.bats pins this, including a negative control that fails if an unstaged LF copy ever starts appearing to verify -- without it, the positive test would stop being evidence.
1 parent 07b2786 commit 7aefeaf

4 files changed

Lines changed: 158 additions & 7 deletions

File tree

‎.goreleaser.yaml‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ before:
1212
# the release as basecamp_installer.ps1. Staged outside dist/ because
1313
# goreleaser requires dist to be empty after before-hooks run. Unsigned
1414
# copy when the SM_* env is absent (forks, make test-release). Raw-main
15-
# install.ps1 stays unsigned.
16-
- sh -c 'mkdir -p .release-extra && cp scripts/install.ps1 .release-extra/basecamp_installer.ps1 && scripts/sign-windows.sh windows .release-extra/basecamp_installer.ps1'
15+
# install.ps1 stays unsigned and LF -- that is the `irm | iex` path,
16+
# which needs neither. The staging step converts to CRLF and rejects
17+
# non-ASCII, without which PowerShell reports HashMismatch (see the
18+
# script for why both invariants are load-bearing).
19+
- sh -c 'scripts/stage-installer-ps1.sh scripts/install.ps1 .release-extra/basecamp_installer.ps1 && scripts/sign-windows.sh windows .release-extra/basecamp_installer.ps1'
1720

1821
builds:
1922
- id: basecamp

‎e2e/installer_signing.bats‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#!/usr/bin/env bats
2+
# installer_signing.bats - Tests for the shape the PowerShell installer must
3+
# have before Authenticode signing.
4+
#
5+
# v0.8.0-rc.2 shipped a basecamp_installer.ps1 that PowerShell rejected as
6+
# HashMismatch, so the installer would not run under AllSigned/RemoteSigned.
7+
# Cause: jsign digests the script as UTF-16LE of the content with NO
8+
# line-ending normalization, while PowerShell's script SIP normalizes to CRLF
9+
# first. An LF file therefore yields two different digests.
10+
#
11+
# A second, latent cause would have survived a CRLF-only fix: the file carries
12+
# no BOM, jsign assumes UTF-8, and Windows PowerShell 5.1 assumes ANSI
13+
# (Windows-1252). Any byte above 0x7F decodes differently on each side. Within
14+
# ASCII the encodings are identical, so staging rejects non-ASCII outright.
15+
16+
setup() {
17+
STAGE="${BATS_TEST_DIRNAME}/../scripts/stage-installer-ps1.sh"
18+
INSTALL_PS1="${BATS_TEST_DIRNAME}/../scripts/install.ps1"
19+
WORK="$(mktemp -d)"
20+
}
21+
22+
teardown() {
23+
[[ -n "${WORK:-}" ]] && rm -rf "$WORK"
24+
}
25+
26+
@test "install.ps1 is pure ASCII so staging can succeed at release time" {
27+
# Guards the source, not just the staged copy: a stray em-dash here fails
28+
# the release, and this is where it is cheap to catch.
29+
run env LC_ALL=C grep -n '[^[:print:][:space:]]' "$INSTALL_PS1"
30+
[ "$status" -ne 0 ]
31+
}
32+
33+
@test "staging converts LF to CRLF" {
34+
run "$STAGE" "$INSTALL_PS1" "$WORK/out.ps1"
35+
[ "$status" -eq 0 ]
36+
# Every line ends CRLF; none is bare LF.
37+
total=$(wc -l < "$WORK/out.ps1")
38+
crlf=$(grep -c $'\r$' "$WORK/out.ps1")
39+
[ "$total" -eq "$crlf" ]
40+
}
41+
42+
@test "staging is idempotent (no CR CR LF on a second pass)" {
43+
"$STAGE" "$INSTALL_PS1" "$WORK/once.ps1"
44+
"$STAGE" "$WORK/once.ps1" "$WORK/twice.ps1"
45+
run cmp -s "$WORK/once.ps1" "$WORK/twice.ps1"
46+
[ "$status" -eq 0 ]
47+
run grep -c $'\r\r' "$WORK/twice.ps1"
48+
[ "$status" -ne 0 ]
49+
}
50+
51+
@test "staging rejects non-ASCII rather than signing an unverifiable file" {
52+
printf '# dash \xe2\x80\x94 here\nWrite-Host x\n' > "$WORK/bad.ps1"
53+
run "$STAGE" "$WORK/bad.ps1" "$WORK/bad-out.ps1"
54+
[ "$status" -ne 0 ]
55+
[[ "$output" == *"non-ASCII"* ]]
56+
[ ! -f "$WORK/bad-out.ps1" ]
57+
}
58+
59+
@test "staged file makes jsign's digest and PowerShell's agree" {
60+
# The actual property under test. jsign hashes UTF-16LE of the content as-is;
61+
# PowerShell hashes UTF-16LE after normalizing to CRLF. On a correctly staged
62+
# file those are the same bytes, so the digests match and the signature
63+
# verifies. This reproduces both sides rather than trusting the shape.
64+
"$STAGE" "$INSTALL_PS1" "$WORK/out.ps1"
65+
run python3 -c '
66+
import hashlib, sys
67+
raw = open(sys.argv[1], "rb").read()
68+
txt = raw.decode("utf-8")
69+
jsign = hashlib.sha256(txt.encode("utf-16-le")).hexdigest()
70+
ps = hashlib.sha256(txt.replace("\r\n", "\n").replace("\n", "\r\n").encode("utf-16-le")).hexdigest()
71+
print("MATCH" if jsign == ps else f"DIFFER jsign={jsign} powershell={ps}")
72+
' "$WORK/out.ps1"
73+
[ "$status" -eq 0 ]
74+
[ "$output" = "MATCH" ]
75+
}
76+
77+
@test "an unstaged LF copy would NOT verify (the rc.2 regression)" {
78+
# Negative control: without staging, the two digests diverge. If this ever
79+
# passes, the digest model above has stopped reflecting reality and the
80+
# positive test is no longer evidence of anything.
81+
run python3 -c '
82+
import hashlib, sys
83+
raw = open(sys.argv[1], "rb").read()
84+
txt = raw.decode("utf-8")
85+
jsign = hashlib.sha256(txt.encode("utf-16-le")).hexdigest()
86+
ps = hashlib.sha256(txt.replace("\r\n", "\n").replace("\n", "\r\n").encode("utf-16-le")).hexdigest()
87+
print("MATCH" if jsign == ps else "DIFFER")
88+
' "$INSTALL_PS1"
89+
[ "$status" -eq 0 ]
90+
[ "$output" = "DIFFER" ]
91+
}

‎scripts/install.ps1‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ function Get-LatestVersion {
8888

8989
function Download-File([string]$Url, [string]$Destination) {
9090
# -UseBasicParsing avoids initializing IE's MSHTML parser on Windows
91-
# PowerShell 5.1 — required on Server Core and locked-down installs.
91+
# PowerShell 5.1 -- required on Server Core and locked-down installs.
9292
# No-op on PowerShell 6+, where basic parsing is the only mode.
9393
Invoke-WebRequest -UseBasicParsing -ErrorAction Stop `
9494
-Headers @{ 'User-Agent' = 'basecamp-cli-installer' } `
@@ -130,7 +130,7 @@ function Verify-CosignSignature([string]$Version, [string]$BaseUrl, [string]$Tmp
130130
Download-File -Url "$BaseUrl/checksums.txt.bundle" -Destination $bundlePath
131131

132132
# Native exits don't trigger ErrorActionPreference=Stop on Windows PowerShell 5.1,
133-
# so check $LASTEXITCODE explicitly — otherwise a verify failure would false-green.
133+
# so check $LASTEXITCODE explicitly -- otherwise a verify failure would false-green.
134134
& cosign verify-blob `
135135
--bundle $bundlePath `
136136
--certificate-identity "https://github.com/basecamp/basecamp-cli/.github/workflows/release.yml@refs/tags/v$Version" `
@@ -268,7 +268,7 @@ function Test-InteractiveSession {
268268
#
269269
# Cross-version: newer binaries expose the intent-neutral `setup agents`. Older
270270
# release binaries (the hosted install.ps1 from main can outrun the latest
271-
# release) fall back WITHOUT reintroducing the Claude-first bug — only an
271+
# release) fall back WITHOUT reintroducing the Claude-first bug -- only an
272272
# *explicitly* selected agent is connected. `all` runs every per-agent setup the
273273
# binary supports; unset/auto/ambiguous installs the shared skill only. Each
274274
# native call is individually guarded so a nonzero exit never aborts the install.
@@ -300,7 +300,7 @@ function Invoke-PostInstallSetup([string]$Binary) {
300300
$ranAgent = $false
301301
foreach ($agent in @('claude', 'codex')) {
302302
if ($help -match "(?m)^\s+$agent\s") {
303-
# Mark attempted (not succeeded) — matches install.sh's `ran_agent=1`,
303+
# Mark attempted (not succeeded) -- matches install.sh's `ran_agent=1`,
304304
# which is set regardless of the setup call's exit status.
305305
$ranAgent = $true
306306
try { & $Binary setup $agent } catch { }
@@ -359,7 +359,7 @@ function Main {
359359

360360
New-Item -ItemType Directory -Force -Path $BinDir | Out-Null
361361
# Windows holds an exclusive lock on running PE files; -Force doesn't help.
362-
# Generic catch — typed catches miss ActionPreferenceStopException wrapping.
362+
# Generic catch -- typed catches miss ActionPreferenceStopException wrapping.
363363
try {
364364
Copy-Item -Force $binaryPath $installedBinary -ErrorAction Stop
365365
} catch {

‎scripts/stage-installer-ps1.sh‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env sh
2+
# Usage: scripts/stage-installer-ps1.sh SOURCE DEST
3+
#
4+
# Stage a copy of the PowerShell installer for Authenticode signing, in the
5+
# only shape where jsign's digest and PowerShell's agree.
6+
#
7+
# jsign computes the script digest as UTF-16LE of the file content with the
8+
# signature block stripped, and applies NO line-ending normalization
9+
# (SignableScript.computeDigest). PowerShell's script SIP normalizes to CRLF
10+
# before hashing. Sign an LF file and the two digests differ, so
11+
# Get-AuthenticodeSignature reports HashMismatch and the installer is refused
12+
# under AllSigned/RemoteSigned. That is what shipped in v0.8.0-rc.2.
13+
#
14+
# Two invariants make the digests agree:
15+
#
16+
# CRLF removes the normalization disagreement above.
17+
#
18+
# pure ASCII removes an encoding disagreement that CRLF alone would not
19+
# fix. The file has no BOM; jsign assumes UTF-8, but Windows
20+
# PowerShell 5.1 assumes ANSI (Windows-1252) for BOM-less files.
21+
# Any byte above 0x7F therefore decodes to different characters
22+
# on each side and the digests diverge again. Within ASCII the
23+
# two encodings are identical byte-for-byte, so the question
24+
# never arises. Adding a BOM would be the other way out, but it
25+
# trades a known-good invariant for jsign's
26+
# isByteOrderMarkSigned() matching Microsoft's undocumented
27+
# treatment of the BOM in the hash — untestable without a
28+
# signing round-trip.
29+
#
30+
# The source file is left alone: raw-main install.ps1 stays LF and unsigned,
31+
# which is the `irm | iex` path and needs neither.
32+
33+
set -eu
34+
35+
SRC="${1:?usage: stage-installer-ps1.sh SOURCE DEST}"
36+
DEST="${2:?usage: stage-installer-ps1.sh SOURCE DEST}"
37+
38+
[ -f "$SRC" ] || { echo "stage-installer-ps1: source not found: $SRC" >&2; exit 1; }
39+
40+
# Reject non-ASCII before signing rather than after. Under LC_ALL=C every byte
41+
# above 0x7F is non-printable, so this matches exactly the bytes that would
42+
# make jsign and PowerShell 5.1 disagree.
43+
if LC_ALL=C grep -n '[^[:print:][:space:]]' "$SRC" >/dev/null 2>&1; then
44+
echo "stage-installer-ps1: $SRC contains non-ASCII bytes, which would produce" >&2
45+
echo " a signature PowerShell 5.1 rejects as HashMismatch (it decodes BOM-less" >&2
46+
echo " files as Windows-1252 while jsign assumes UTF-8). Offending lines:" >&2
47+
LC_ALL=C grep -n '[^[:print:][:space:]]' "$SRC" >&2
48+
exit 1
49+
fi
50+
51+
mkdir -p "$(dirname "$DEST")"
52+
53+
# LF -> CRLF, idempotent: strip any existing CR first so a CRLF source does
54+
# not become CR CR LF.
55+
awk '{ sub(/\r$/, ""); printf "%s\r\n", $0 }' "$SRC" > "$DEST"
56+
57+
echo "stage-installer-ps1: staged $DEST (pure ASCII, CRLF)"

0 commit comments

Comments
 (0)