From 84cf74b9d53bb058720a2bf000693d04c56c03f7 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 30 Jul 2026 16:30:54 -0700 Subject: [PATCH 1/2] Sign Windows executables and steer Smart App Control users Smart App Control blocks the unsigned basecamp.exe we ship, with no per-app exceptions (Help Scout 3402953784). Sign everything Windows in the release pipeline, and meet users on unsigned builds with real diagnosis and workarounds. Release pipeline: - scripts/sign-windows.sh: env-gated Authenticode wrapper (jsign + DigiCert KeyLocker, the same cert infrastructure bc3-desktop uses). Skips cleanly when signing is unconfigured (forks, make test-release); hard-fails on partial config or signing errors so a tagged release can never silently ship unsigned. - goreleaser build post-hook signs each Windows exe in place before archiving, so the zip, checksums.txt, cosign bundle, Scoop manifest hash, and provenance attestation all describe the signed binary. - A signed copy of install.ps1 ships as basecamp_installer.ps1, checksummed and attested like every other artifact. Staged outside dist/ (goreleaser requires dist to be empty after before-hooks). - release.yml: Windows secrets preflight, temurin + pinned jsign 7.5 (sha256-verified), client cert decoded under umask 077 with always() cleanup, and a windows-verify job asserting Status=Valid, CN=37signals LLC, and a timestamp countersignature on both arches, plus the installer signature under both powershell and pwsh. - e2e/sign_windows.bats pins the wrapper's fail-closed contract. Docs and UX for unsigned builds (v0.8.0-rc.1 and earlier): - doctor gains a "Binary Signature" check: presence-only Authenticode probe via debug/pe, a signing-regression canary with an upgrade breadcrumb. No validity claims: Windows enforces those. - install.ps1 diagnoses first-run failures (SAC on, SAC off/quarantine, generic), always leading with the original error. install.sh stops swallowing verify_install stderr and adds the SAC hint on Windows. - README and install.md document Smart App Control: WSL2 preferred, or SAC off staying off until upgraded to a signed build (the March/April 2026 Windows 11 updates make re-enabling reset-free; older builds need a reset). - RELEASING.md: SM_* secrets, cert identity and 2027-04-30 expiry, KeyLocker quota notes (3 signatures per tag). --- .github/workflows/release.yml | 133 ++++++++++++++++++++++++++++ .gitignore | 1 + .goreleaser.yaml | 19 ++++ Makefile | 1 + README.md | 32 +++++++ RELEASING.md | 28 ++++++ e2e/installer.bats | 116 +++++++++++++++++++++++++ e2e/sign_windows.bats | 145 +++++++++++++++++++++++++++++++ install.md | 40 +++++++++ internal/commands/doctor.go | 115 +++++++++++++++++++++--- internal/commands/doctor_test.go | 125 ++++++++++++++++++++++++++ scripts/install.ps1 | 64 +++++++++++++- scripts/install.sh | 22 ++++- scripts/manage-release-env.sh | 3 + scripts/sign-windows.sh | 55 ++++++++++++ 15 files changed, 882 insertions(+), 17 deletions(-) create mode 100644 e2e/sign_windows.bats create mode 100755 scripts/sign-windows.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69b0b0c47..6f86b14fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -228,6 +228,49 @@ jobs: MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + - name: Verify Windows signing secrets + if: github.repository == 'basecamp/basecamp-cli' + run: | + missing=() + for var in SM_API_KEY SM_CLIENT_CERT_FILE_B64 SM_CLIENT_CERT_PASSWORD; do + if [ -z "${!var}" ]; then + missing+=("$var") + fi + done + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Missing Windows signing secrets: ${missing[*]}" + exit 1 + fi + echo "All Windows signing secrets present" + env: + SM_API_KEY: ${{ secrets.SM_API_KEY }} + SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} + SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} + + - name: Set up Java for jsign + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + with: + distribution: temurin + java-version: '21' + + - name: Prepare Windows signing + env: + SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} + run: | + # jsign >= 7.5 is mandatory: 7.1-7.3 are broken against DigiCert + # ONE's current API (bc3-desktop commit 59d6453). + JSIGN_VERSION=7.5 + JSIGN_SHA256=602a51c3545a6dc4fb99bd2ea7152b26d1345916d0c93ddfbd5936cb735af91c + umask 077 + curl -fsSL --retry 3 -o "$RUNNER_TEMP/jsign-$JSIGN_VERSION.jar" \ + "https://github.com/ebourg/jsign/releases/download/$JSIGN_VERSION/jsign-$JSIGN_VERSION.jar" + echo "$JSIGN_SHA256 $RUNNER_TEMP/jsign-$JSIGN_VERSION.jar" | sha256sum -c - + printf '%s' "$SM_CLIENT_CERT_FILE_B64" | base64 -d > "$RUNNER_TEMP/digicert-client-cert.p12" + { + echo "JSIGN_JAR=$RUNNER_TEMP/jsign-$JSIGN_VERSION.jar" + echo "SM_CLIENT_CERT_FILE=$RUNNER_TEMP/digicert-client-cert.p12" + } >> "$GITHUB_ENV" + - name: Install GoReleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: @@ -246,6 +289,14 @@ jobs: MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + # Windows Authenticode signing via DigiCert KeyLocker (jsign). + # JSIGN_JAR and SM_CLIENT_CERT_FILE arrive via GITHUB_ENV from the + # "Prepare Windows signing" step above. + SM_API_KEY: ${{ secrets.SM_API_KEY }} + SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} + # DigiCert ONE certificate ID (not the keypair alias) for the OV cert + # CN=37signals LLC, expires 2027-04-30. Keep in sync with bc3-desktop. + SIGN_ALIAS: 1346fa41-d9f0-4580-b7b9-a95cf5674354 run: | RELEASE_CHANGELOG="" if [ -n "$CHANGELOG_FILE" ] && [ -f "$CHANGELOG_FILE" ]; then @@ -254,6 +305,10 @@ jobs: export RELEASE_CHANGELOG goreleaser release --clean + - name: Clean up Windows signing material + if: always() + run: rm -f "$RUNNER_TEMP/digicert-client-cert.p12" "$RUNNER_TEMP"/jsign-*.jar + - name: Attest build provenance uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: @@ -338,6 +393,84 @@ jobs: echo "::warning::Notarization not yet accepted for ${{ matrix.arch }} (ticket propagation may lag)" fi + windows-verify: + name: Verify Windows signing + needs: [release] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: windows-latest + timeout-minutes: 10 + permissions: + contents: read + strategy: + matrix: + arch: [amd64, arm64] + steps: + - name: Download release binary + env: + GH_TOKEN: ${{ github.token }} + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "$env:RUNNER_TEMP\verify" | Out-Null + gh release download $env:GITHUB_REF_NAME ` + --repo $env:GITHUB_REPOSITORY ` + --pattern "basecamp_*_windows_${{ matrix.arch }}.zip" ` + --dir "$env:RUNNER_TEMP\verify" + if ($LASTEXITCODE -ne 0) { throw 'gh release download failed' } + $zip = Get-ChildItem "$env:RUNNER_TEMP\verify\basecamp_*_windows_${{ matrix.arch }}.zip" + Expand-Archive -Path $zip.FullName -DestinationPath "$env:RUNNER_TEMP\verify\extract" -Force + + - name: Verify Authenticode signature + shell: pwsh + run: | + # Signature validation of an arm64 PE needs no execution, so both + # arches verify on the x64 runner. + $sig = Get-AuthenticodeSignature "$env:RUNNER_TEMP\verify\extract\basecamp.exe" + Write-Host "Status (${{ matrix.arch }}): $($sig.Status) - $($sig.StatusMessage)" + Write-Host "Signer: $($sig.SignerCertificate.Subject)" + if ($sig.Status -ne 'Valid') { + Write-Host "::error::Authenticode status (${{ matrix.arch }}): $($sig.Status) - $($sig.StatusMessage)" + exit 1 + } + if ($sig.SignerCertificate.Subject -notmatch 'CN=37signals LLC') { + Write-Host "::error::Unexpected signer (${{ matrix.arch }}): $($sig.SignerCertificate.Subject)" + exit 1 + } + if (-not $sig.TimeStamperCertificate) { + Write-Host "::error::No timestamp countersignature (${{ matrix.arch }}) - signature would expire with the cert on 2027-04-30" + exit 1 + } + Write-Host "Signature valid, signer and timestamp verified (${{ matrix.arch }})" + + - name: Download installer asset + if: matrix.arch == 'amd64' + env: + GH_TOKEN: ${{ github.token }} + shell: pwsh + run: | + gh release download $env:GITHUB_REF_NAME ` + --repo $env:GITHUB_REPOSITORY ` + --pattern 'basecamp_installer.ps1' ` + --dir "$env:RUNNER_TEMP\verify" + if ($LASTEXITCODE -ne 0) { throw 'gh release download failed' } + + # Both engines: confirms jsign's ps1 content-hash encoding matches what + # PowerShell computes, on Core and on Windows PowerShell 5.1. + - name: Verify installer signature (pwsh) + if: matrix.arch == 'amd64' + shell: pwsh + run: | + $sig = Get-AuthenticodeSignature "$env:RUNNER_TEMP\verify\basecamp_installer.ps1" + Write-Host "Installer status (pwsh): $($sig.Status) - $($sig.StatusMessage)" + if ($sig.Status -ne 'Valid') { exit 1 } + + - name: Verify installer signature (Windows PowerShell 5.1) + if: matrix.arch == 'amd64' + shell: powershell + run: | + $sig = Get-AuthenticodeSignature "$env:RUNNER_TEMP\verify\basecamp_installer.ps1" + Write-Host "Installer status (powershell): $($sig.Status) - $($sig.StatusMessage)" + if ($sig.Status -ne 'Valid') { exit 1 } + nix-verify: name: Verify Nix flake needs: [release] diff --git a/.gitignore b/.gitignore index f44d2ffcf..31a00285f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ e2e/recorder/recorder # GoReleaser output dist/ +.release-extra/ # Test coverage coverage.out diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 03065ec32..fa8f4d24c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -7,6 +7,13 @@ before: hooks: - sh -c 'mkdir -p completions && go run ./cmd/basecamp completion bash > completions/basecamp.bash && go run ./cmd/basecamp completion zsh > completions/_basecamp && go run ./cmd/basecamp completion fish > completions/basecamp.fish' - sh -c '{{ if .Prerelease }}echo "Skipping plugin version stamps for prerelease {{ .Version }}"{{ else }}scripts/stamp-plugin-version.sh {{ .Version }} && scripts/stamp-codex-plugin-version.sh {{ .Version }}{{ end }}' + # Authenticode-sign a copy of the PowerShell installer inside the pipeline + # so it lands in checksums.txt (cosign-signed, provenance-attested) and on + # the release as basecamp_installer.ps1. Staged outside dist/ because + # goreleaser requires dist to be empty after before-hooks run. Unsigned + # copy when the SM_* env is absent (forks, make test-release). Raw-main + # install.ps1 stays unsigned. + - 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' builds: - id: basecamp @@ -30,6 +37,14 @@ builds: - -X github.com/basecamp/basecamp-cli/internal/version.Version={{.Version}} - -X github.com/basecamp/basecamp-cli/internal/version.Commit={{.Commit}} - -X github.com/basecamp/basecamp-cli/internal/version.Date={{.Date}} + hooks: + post: + # Authenticode-sign Windows binaries in place before archiving, so the + # zip, checksums.txt, cosign bundle, and provenance attestation all + # cover the signed binary. No-op for non-windows targets and when the + # SM_* signing env is absent (forks, make test-release). + - cmd: scripts/sign-windows.sh "{{ .Target }}" "{{ .Path }}" + output: true archives: - id: default @@ -76,6 +91,8 @@ nfpms: checksum: name_template: 'checksums.txt' algorithm: sha256 + extra_files: + - glob: .release-extra/basecamp_installer.ps1 # Generate SBOM for supply chain transparency sboms: @@ -122,6 +139,8 @@ release: prerelease: auto make_latest: "{{ if .Prerelease }}false{{ else }}auto{{ end }}" replace_existing_artifacts: true + extra_files: + - glob: .release-extra/basecamp_installer.ps1 name_template: "{{.ProjectName}} v{{.Version}}" header: | {{ if .Env.RELEASE_CHANGELOG }}{{ .Env.RELEASE_CHANGELOG }}{{ end }} diff --git a/Makefile b/Makefile index aa0757cdb..39f3de0fe 100644 --- a/Makefile +++ b/Makefile @@ -352,6 +352,7 @@ release: .PHONY: test-release test-release: MACOS_SIGN_P12= MACOS_SIGN_PASSWORD= MACOS_NOTARY_KEY= MACOS_NOTARY_KEY_ID= MACOS_NOTARY_ISSUER_ID= \ + SM_API_KEY= SM_CLIENT_CERT_FILE= SM_CLIENT_CERT_PASSWORD= \ goreleaser release --snapshot --skip=publish,sign --clean # Verify the committed CLI surface snapshot (.surface) matches the command tree. diff --git a/README.md b/README.md index c578b785d..0b307994e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ curl -fsSL https://basecamp.com/install-cli | bash irm https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.ps1 | iex ``` +On Windows 11 with Smart App Control, see [Troubleshooting](#windows-smart-app-control-and-smartscreen) if the install is blocked. + That's it. You now have full access to Basecamp from your terminal.
@@ -204,6 +206,36 @@ basecamp doctor --verbose # Verbose output with details basecamp doctor --json # Structured checks, including Claude and Codex ``` +### Windows: Smart App Control and SmartScreen + +Releases up to v0.8.0-rc.1 ship an unsigned `basecamp.exe`. To check whether +your installed binary is signed: + +```powershell +Get-AuthenticodeSignature (Get-Command basecamp).Source +``` + +**Smart App Control** (Windows 11) blocks unsigned executables no matter where +they were downloaded from, and it has no per-app exceptions — this applies to +the PowerShell installer, Scoop installs, and manual downloads alike. If it +blocks an unsigned `basecamp.exe`, two options: + +1. **Use WSL2 (preferred).** Install the Linux build inside WSL2 — Smart App + Control doesn't apply there and your Windows security setup is untouched: + `wsl --install`, then inside the WSL terminal: + `curl -fsSL https://basecamp.com/install-cli | bash` +2. **Turn Smart App Control off** (Windows Security → App & browser control → + Smart App Control settings) **and leave it off while using the unsigned + build.** Because there are no per-app exceptions, turning it back on + re-blocks `basecamp.exe` on its next run — only re-enable after upgrading + to a signed build. Windows 11 with the March/April 2026 updates can + re-enable Smart App Control from Windows Security without a reset; on older + builds re-enabling requires resetting Windows, so prefer WSL2 there. + +**SmartScreen** (without Smart App Control) may warn on first run of an +unrecognized executable — choose "More info" → "Run anyway" if you downloaded +the release from this repository. + ## Development ```bash diff --git a/RELEASING.md b/RELEASING.md index 78f479097..1b09ca83c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -32,6 +32,8 @@ make release VERSION=0.2.0 DRY_RUN=1 - Builds binaries for all platforms (darwin, linux, windows, freebsd, openbsd × amd64/arm64) - Builds `.deb`, `.rpm`, `.apk` Linux packages (amd64 + arm64) - Signs and notarizes macOS binaries via GoReleaser's built-in notarize (embedded quill) + - Signs Windows binaries with Authenticode via jsign and DigiCert KeyLocker + - Signs a copy of the PowerShell installer and attaches it as `basecamp_installer.ps1` - Signs checksums with cosign (keyless via Sigstore OIDC) - Generates SBOM for supply chain transparency - Updates Homebrew cask (`basecamp-cli`) in `basecamp/homebrew-tap` for stable tags @@ -94,6 +96,32 @@ basecamp skill install | `MACOS_NOTARY_KEY` | Base64-encoded App Store Connect API key (.p8) | | `MACOS_NOTARY_KEY_ID` | App Store Connect API key ID (10 characters) | | `MACOS_NOTARY_ISSUER_ID` | App Store Connect issuer UUID | +| `SM_API_KEY` | DigiCert ONE API key for KeyLocker | +| `SM_CLIENT_CERT_FILE_B64` | Base64-encoded DigiCert ONE mTLS client certificate (.p12) | +| `SM_CLIENT_CERT_PASSWORD` | Client certificate unlock password | + +## Windows signing + +Windows binaries and the installer copy are Authenticode-signed from Linux CI +via [jsign](https://ebourg.github.io/jsign/) against DigiCert KeyLocker (cloud +HSM) — no Windows runner or hardware token involved. bc3-desktop's +`docs/windows-signing.md` is the canonical runbook; this repo is a second +consumer of the same certificate. + +- Certificate: OV code signing, `CN=37signals LLC`, expires **2027-04-30**. + The DigiCert ONE certificate ID is pinned once, in + `.github/workflows/release.yml` (`SIGN_ALIAS`) — keep it in sync with + bc3-desktop when the certificate is renewed. +- jsign version and jar sha256 are pinned in the release workflow's + "Prepare Windows signing" step. jsign ≥ 7.5 is required — 7.1–7.3 are + broken against DigiCert ONE's current API. +- Quota: KeyLocker signatures draw from a budget shared with bc3-desktop. + Each tag consumes 3 signatures (2 exes + the installer copy); a release + cycle of rc(s) + stable is ≥ 6, and workflow re-runs after post-signing + failures consume more. Confirm headroom with the bc3-desktop cert owner + before a release burst. +- Signing failures abort the release during the build phase — nothing is + published. Re-run the workflow on the same tag after the outage clears. ## AUR setup (one-time) diff --git a/e2e/installer.bats b/e2e/installer.bats index ee10cd96e..b7cc57189 100644 --- a/e2e/installer.bats +++ b/e2e/installer.bats @@ -275,3 +275,119 @@ EOF [[ "$output" == *"restored-value-ok"* ]] [[ "$output" == *"nk=1 setup agents"* ]] } + +# Smart App Control blocks unsigned executables at process creation (releases +# up to v0.8.0-rc.1 ship an unsigned basecamp.exe), so the install scripts +# must surface WHY the first run failed instead of a bare "not working". + +@test "verify_install surfaces stderr and adds the Windows SAC hint" { + cat > "$STUB_DIR/basecamp.exe" <<'EOF' +#!/usr/bin/env bash +echo "simulated block: cannot execute" >&2 +exit 126 +EOF + chmod +x "$STUB_DIR/basecamp.exe" + + run bash -c " + set -euo pipefail + source '$INSTALL_SH' + BIN_DIR='$STUB_DIR' + verify_install windows_amd64 + " + [[ "$status" -ne 0 ]] + [[ "$output" == *"simulated block: cannot execute"* ]] + [[ "$output" == *"Smart App Control"* ]] + [[ "$output" == *"#windows-smart-app-control-and-smartscreen"* ]] +} + +@test "verify_install on linux surfaces stderr without the Windows hint" { + cat > "$STUB_DIR/basecamp" <<'EOF' +#!/usr/bin/env bash +echo "simulated failure" >&2 +exit 1 +EOF + chmod +x "$STUB_DIR/basecamp" + + run bash -c " + set -euo pipefail + source '$INSTALL_SH' + BIN_DIR='$STUB_DIR' + verify_install linux_amd64 + " + [[ "$status" -ne 0 ]] + [[ "$output" == *"simulated failure"* ]] + [[ "$output" != *"Smart App Control"* ]] +} + +@test "install.ps1 carries the Smart App Control first-run diagnosis" { + grep -q 'function Get-FirstRunFailureMessage' "$INSTALL_PS1" + grep -q 'Smart App Control' "$INSTALL_PS1" + grep -q 'Protection history' "$INSTALL_PS1" + # The first-run failure path routes through the diagnosis helper. + grep -qF 'Fail (Get-FirstRunFailureMessage' "$INSTALL_PS1" +} + +# All three diagnosis branches, driven by shadowing the probes. Same AST +# extraction pattern as the BASECAMP_NO_KEYRING test above: only the function +# under test is evaluated, Main never runs. +@test "install.ps1 Get-FirstRunFailureMessage diagnoses SAC, quarantine, and generic failures" { + if ! command -v pwsh >/dev/null 2>&1; then + if [[ -n "${CI:-}" ]]; then + echo "pwsh is required in CI for install.ps1 diagnosis coverage" >&2 + return 1 + fi + skip "pwsh not installed" + fi + + cat > "$STUB_DIR/sac-driver.ps1" <<'EOF' +$ErrorActionPreference = 'Stop' +$tokens = $null; $parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($env:INSTALL_PS1_PATH, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -gt 0) { throw "install.ps1 parse errors: $($parseErrors -join '; ')" } +$fn = $ast.Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Get-FirstRunFailureMessage' }, $true) +if (-not $fn) { throw 'Get-FirstRunFailureMessage not found in install.ps1' } +. ([scriptblock]::Create($fn.Extent.Text)) + +# Shadow the probes the helper relies on. Advanced functions so the helper's +# -ErrorAction Stop common parameter binds. +$script:SigStatus = 'NotSigned' +$script:SacState = 1 +function Get-AuthenticodeSignature { [CmdletBinding()] param([string]$FilePath) [pscustomobject]@{ Status = $script:SigStatus } } +function Get-ItemProperty { [CmdletBinding()] param([string]$Path) [pscustomobject]@{ VerifiedAndReputablePolicyState = $script:SacState } } + +# Branch 1: unsigned binary, SAC on. +$msg = Get-FirstRunFailureMessage -Binary 'C:\bin\basecamp.exe' -Reason 'boom-sac' +if ($msg -notmatch '^Installed basecamp\.exe .+ running it failed: boom-sac') { throw "branch1 does not lead with the original failure: $msg" } +if ($msg -notlike '*Smart App Control*') { throw 'branch1 missing SAC explanation' } +if ($msg -notlike '*wsl --install*') { throw 'branch1 missing WSL option' } +if ($msg -notlike '*no per-app exceptions*') { throw 'branch1 missing no-exceptions caveat' } +if ($msg -notlike '*leave it off while using this unsigned build*') { throw 'branch1 missing stay-off caveat' } +'branch1-ok' + +# Branch 2: unsigned binary, SAC off — quarantine advice, no WSL pitch. +$script:SacState = 0 +$msg = Get-FirstRunFailureMessage -Binary 'C:\bin\basecamp.exe' -Reason 'boom-quarantine' +if ($msg -notlike '*boom-quarantine*') { throw 'branch2 missing original failure' } +if ($msg -notlike '*Protection history*') { throw 'branch2 missing Protection history advice' } +if ($msg -like '*wsl --install*') { throw 'branch2 should not pitch WSL' } +'branch2-ok' + +# Branch 3: signed binary — generic hint, no unsigned claim. +$script:SigStatus = 'Valid' +$msg = Get-FirstRunFailureMessage -Binary 'C:\bin\basecamp.exe' -Reason 'boom-generic' +if ($msg -notlike '*boom-generic*') { throw 'branch3 missing original failure' } +if ($msg -notlike '*Protection history*') { throw 'branch3 missing generic hint' } +if ($msg -like '*not code-signed*') { throw 'branch3 must not claim the binary is unsigned' } +'branch3-ok' +EOF + + run bash -c " + set -euo pipefail + export INSTALL_PS1_PATH='$INSTALL_PS1' + pwsh -NoProfile -File '$STUB_DIR/sac-driver.ps1' + " + [[ "$status" -eq 0 ]] + [[ "$output" == *"branch1-ok"* ]] + [[ "$output" == *"branch2-ok"* ]] + [[ "$output" == *"branch3-ok"* ]] +} diff --git a/e2e/sign_windows.bats b/e2e/sign_windows.bats new file mode 100644 index 000000000..dba956adc --- /dev/null +++ b/e2e/sign_windows.bats @@ -0,0 +1,145 @@ +#!/usr/bin/env bats +# sign_windows.bats - Tests for scripts/sign-windows.sh's fail-closed contract. +# +# The wrapper gates Authenticode signing in the release pipeline: it must +# skip cleanly when signing is unconfigured (forks, make test-release), but +# hard-fail on partial configuration or missing material so a tagged release +# can never silently ship unsigned. + +setup() { + SIGN_SH="${BATS_TEST_DIRNAME}/../scripts/sign-windows.sh" + + STUB_DIR="$(mktemp -d)" + LOG="$STUB_DIR/java-args.log" + TARGET_FILE="$STUB_DIR/basecamp.exe" + CERT_FILE="$STUB_DIR/client-cert.p12" + JAR_FILE="$STUB_DIR/jsign.jar" + echo "pe-bytes" > "$TARGET_FILE" + echo "cert" > "$CERT_FILE" + echo "jar" > "$JAR_FILE" + + # A `java` stub that records its argv one-per-line and exits JAVA_EXIT. + { + echo '#!/usr/bin/env bash' + echo "printf '%s\\n' \"\$@\" > \"$LOG\"" + echo 'exit "${JAVA_EXIT:-0}"' + } > "$STUB_DIR/java" + chmod +x "$STUB_DIR/java" +} + +teardown() { + [[ -n "${STUB_DIR:-}" ]] && rm -rf "$STUB_DIR" +} + +# run_sign VAR=VALUE... TARGET — runs the wrapper with exactly the given +# signing env (everything else cleared), against $TARGET_FILE. +run_sign() { + local env_args=() + while [[ "$1" == *=* ]]; do + env_args+=("$1") + shift + done + run env -u SM_API_KEY -u SM_CLIENT_CERT_FILE -u SM_CLIENT_CERT_PASSWORD \ + -u SIGN_ALIAS -u JSIGN_JAR "${env_args[@]}" \ + PATH="$STUB_DIR:$PATH" \ + "$SIGN_SH" "$1" "$TARGET_FILE" +} + +full_env() { + echo "SM_API_KEY=api-key" \ + "SM_CLIENT_CERT_FILE=$CERT_FILE" \ + "SM_CLIENT_CERT_PASSWORD=cert-pass" \ + "SIGN_ALIAS=alias-guid" \ + "JSIGN_JAR=$JAR_FILE" +} + +@test "non-windows target is a silent no-op even with full signing env" { + # shellcheck disable=SC2046 + run_sign $(full_env) darwin_amd64 + [[ "$status" -eq 0 ]] + [[ -z "$output" ]] + [[ "$(cat "$TARGET_FILE")" == "pe-bytes" ]] + [[ ! -f "$LOG" ]] # java never invoked +} + +@test "all SM_* empty skips with a notice" { + run_sign windows_amd64 + [[ "$status" -eq 0 ]] + [[ "$output" == *"skipping Authenticode signing"* ]] + [[ ! -f "$LOG" ]] +} + +@test "skip notice applies to the plain 'windows' target too" { + run_sign windows + [[ "$status" -eq 0 ]] + [[ "$output" == *"skipping Authenticode signing"* ]] +} + +# Partial configuration is a misconfigured release, not a fork building from +# source — each missing var must hard-fail and be named. +@test "partial config: each empty var fails naming the var" { + local var + for var in SM_API_KEY SM_CLIENT_CERT_FILE SM_CLIENT_CERT_PASSWORD SIGN_ALIAS JSIGN_JAR; do + local env_args=() + local pair + for pair in $(full_env); do + if [[ "$pair" == "$var="* ]]; then + env_args+=("$var=") + else + env_args+=("$pair") + fi + done + run env -u SM_API_KEY -u SM_CLIENT_CERT_FILE -u SM_CLIENT_CERT_PASSWORD \ + -u SIGN_ALIAS -u JSIGN_JAR "${env_args[@]}" \ + PATH="$STUB_DIR:$PATH" \ + "$SIGN_SH" windows_amd64 "$TARGET_FILE" + [[ "$status" -eq 1 ]] || { echo "expected exit 1 for empty $var, got $status"; return 1; } + [[ "$output" == *"$var is empty"* ]] || { echo "missing var name for $var in: $output"; return 1; } + done +} + +@test "nonexistent client cert path fails" { + run_sign SM_API_KEY=api-key "SM_CLIENT_CERT_FILE=$STUB_DIR/missing.p12" \ + SM_CLIENT_CERT_PASSWORD=cert-pass SIGN_ALIAS=alias-guid "JSIGN_JAR=$JAR_FILE" \ + windows_amd64 + [[ "$status" -eq 1 ]] + [[ "$output" == *"client cert not found"* ]] +} + +@test "nonexistent jsign jar path fails" { + run_sign SM_API_KEY=api-key "SM_CLIENT_CERT_FILE=$CERT_FILE" \ + SM_CLIENT_CERT_PASSWORD=cert-pass SIGN_ALIAS=alias-guid \ + "JSIGN_JAR=$STUB_DIR/missing.jar" windows_amd64 + [[ "$status" -eq 1 ]] + [[ "$output" == *"jsign jar not found"* ]] +} + +@test "happy path invokes jsign with the exact KeyLocker argv" { + # shellcheck disable=SC2046 + run_sign $(full_env) windows_amd64 + [[ "$status" -eq 0 ]] + [[ "$output" == *"signing $TARGET_FILE via DigiCert KeyLocker"* ]] + + expected="-jar +$JAR_FILE +--storetype +DIGICERTONE +--alias +alias-guid +--storepass +api-key|$CERT_FILE|cert-pass +--tsaurl +http://timestamp.digicert.com +--tsretries +3 +--tsretrywait +10 +$TARGET_FILE" + [[ "$(cat "$LOG")" == "$expected" ]] +} + +@test "jsign failure propagates its exit code" { + # shellcheck disable=SC2046 + run_sign $(full_env) JAVA_EXIT=7 windows_amd64 + [[ "$status" -eq 7 ]] +} diff --git a/install.md b/install.md index 6fe8b7d69..79489af90 100644 --- a/install.md +++ b/install.md @@ -37,6 +37,8 @@ irm https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install > - PowerShell: `$env:BASECAMP_SETUP_AGENT='codex'; irm https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.ps1 | iex` > > **Windows note:** if `curl` fails with a `schannel` / `CRYPT_E_NO_REVOCATION_CHECK` TLS error, prefer the PowerShell installer, Scoop, or Git Bash's `/usr/bin/curl` instead of the system `curl.exe`. +> +> **Windows 11 with Smart App Control:** releases up to v0.8.0-rc.1 ship an unsigned `basecamp.exe`, which Smart App Control blocks. Prefer WSL2 — run `curl -fsSL https://basecamp.com/install-cli | bash` inside a WSL terminal — or see [Troubleshooting](#troubleshooting) below. Alternatively install manually: @@ -189,6 +191,44 @@ basecamp auth logout && basecamp auth login basecamp auth login --scope full ``` +**Windows 11: Smart App Control blocks `basecamp.exe`:** + +Releases up to v0.8.0-rc.1 ship an unsigned `basecamp.exe`. Smart App Control +only runs code-signed executables — regardless of download source — and has no +per-app exceptions, so it blocks the unsigned CLI at launch. Check whether an +installed binary is signed with: + +```powershell +Get-AuthenticodeSignature (Get-Command basecamp).Source +``` + +Preferred workaround — install inside WSL2, where Smart App Control doesn't +apply and your Windows security setup is untouched: + +```powershell +wsl --install +``` + +then inside the WSL terminal: + +```bash +curl -fsSL https://basecamp.com/install-cli | bash +``` + +The DONE WHEN gate (`basecamp --version && basecamp auth status`) runs inside +WSL in this setup. + +Alternative — turn Smart App Control off (Windows Security → App & browser +control → Smart App Control settings) and leave it off while using the +unsigned build: because there are no per-app exceptions, re-enabling it +re-blocks `basecamp.exe` on its next run. Only re-enable after upgrading to a +signed build. Windows 11 with the March/April 2026 updates can re-enable Smart +App Control from Windows Security without a reset; on older builds re-enabling +requires resetting Windows, so use WSL2 there instead. + +Plain SmartScreen (Smart App Control off) may warn on first run — choose +"More info" → "Run anyway". + **Termux / Android (`SIGSYS: bad system call` on startup):** On Termux, the prebuilt binaries and diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go index 00fb3b2dc..ac05bda3f 100644 --- a/internal/commands/doctor.go +++ b/internal/commands/doctor.go @@ -4,6 +4,7 @@ package commands import ( "bufio" "context" + "debug/pe" "encoding/json" "fmt" "io" @@ -79,6 +80,7 @@ func NewDoctorCmd() *cobra.Command { The doctor command helps troubleshoot common issues by checking: - CLI version (and whether updates are available) + - Binary signature (Windows: Authenticode presence) - Configuration files (existence and validity) - Authentication credentials - Token validity and expiration @@ -131,22 +133,27 @@ func runDoctorChecks(ctx context.Context, app *appctx.App, verbose bool) []Check // 1. Version check checks = append(checks, checkVersion(verbose)) //nolint:contextcheck // checkVersion uses fetchLatestVersion which creates its own bounded context intentionally - // 2. SDK provenance + // 2. Binary signature (Windows release builds only) + if sigCheck := checkBinarySignature(); sigCheck != nil { + checks = append(checks, *sigCheck) + } + + // 3. SDK provenance checks = append(checks, checkSDKProvenance(verbose)) - // 3. Go runtime info (verbose only, always passes) + // 4. Go runtime info (verbose only, always passes) if verbose { checks = append(checks, checkRuntime()) } - // 4. Config files check + // 5. Config files check checks = append(checks, checkConfigFiles(app, verbose)...) - // 5. Credentials check + // 6. Credentials check credCheck := checkCredentials(app, verbose) checks = append(checks, credCheck) - // 6. Authentication check (only if credentials exist) + // 7. Authentication check (only if credentials exist) var canTestAPI bool if credCheck.Status == "pass" || credCheck.Status == "warn" { authCheck := checkAuthentication(ctx, app, verbose) @@ -161,7 +168,7 @@ func runDoctorChecks(ctx context.Context, app *appctx.App, verbose bool) []Check }) } - // 7. API connectivity (only if authenticated) + // 8. API connectivity (only if authenticated) if canTestAPI { checks = append(checks, checkAPIConnectivity(ctx, app, verbose)) } else { @@ -172,7 +179,7 @@ func runDoctorChecks(ctx context.Context, app *appctx.App, verbose bool) []Check }) } - // 8. Account access (only if API works) + // 9. Account access (only if API works) if canTestAPI && app.Config.AccountID != "" { checks = append(checks, checkAccountAccess(ctx, app, verbose)) } else if app.Config.AccountID == "" { @@ -190,18 +197,18 @@ func runDoctorChecks(ctx context.Context, app *appctx.App, verbose bool) []Check }) } - // 9. Cache health + // 10. Cache health checks = append(checks, checkCacheHealth(app, verbose)) - // 10. Shell completion + // 11. Shell completion checks = append(checks, checkShellCompletion(verbose)) - // 11. Legacy bcq detection + // 12. Legacy bcq detection if legacyCheck := checkLegacyInstall(); legacyCheck != nil { checks = append(checks, *legacyCheck) } - // 12. AI Agent integration (for each detected agent) + // 13. AI Agent integration (for each detected agent) if baselineSkillInstalled() { checks = append(checks, checkSkillVersion()) } @@ -254,6 +261,86 @@ func checkVersion(verbose bool) Check { return check } +// checkBinarySignature reports whether the running Windows executable carries +// an Authenticode signature. It is a signing-regression canary for pipeline +// drift (a release that shipped unsigned), not a validity check: if doctor +// runs at all, Smart App Control didn't block this binary, and Windows — not +// this probe — enforces signature validity and expiry. +func checkBinarySignature() *Check { + return binarySignatureCheck(runtime.GOOS) +} + +// binarySignatureCheck is the platform-injectable core of +// checkBinarySignature. Nil off-Windows and for dev builds (built from +// source; never signed, and a warn there would be pure noise). +func binarySignatureCheck(goos string) *Check { + if goos != "windows" || version.IsDev() { + return nil + } + + var check Check + exe, err := os.Executable() + if err == nil { + var signed bool + if signed, err = hasAuthenticodeSignature(exe); err == nil { + check = signatureCheck(signed) + } + } + if err != nil { + check = Check{ + Name: "Binary Signature", + Status: "warn", + Message: fmt.Sprintf("Could not inspect executable for a signature: %v", err), + } + } + return &check +} + +// signatureCheck composes the Binary Signature result. Presence-only wording: +// never claim the signature is valid or unexpired — the probe cannot see that. +func signatureCheck(signed bool) Check { + if signed { + return Check{ + Name: "Binary Signature", + Status: "pass", + Message: "Authenticode signature present", + } + } + return Check{ + Name: "Binary Signature", + Status: "warn", + Message: "No Authenticode signature (unsigned executable)", + Hint: "Windows can block unsigned executables (Smart App Control, SmartScreen). Run: basecamp upgrade", + } +} + +// hasAuthenticodeSignature reports whether the PE file at path has a +// WIN_CERTIFICATE overlay, i.e. a nonzero security data directory +// (IMAGE_DIRECTORY_ENTRY_SECURITY). Pure debug/pe — works on any platform. +func hasAuthenticodeSignature(path string) (bool, error) { + f, err := pe.Open(path) + if err != nil { + return false, err + } + defer f.Close() + + var dirs []pe.DataDirectory + switch oh := f.OptionalHeader.(type) { + case *pe.OptionalHeader32: + dirs = oh.DataDirectory[:] + case *pe.OptionalHeader64: + dirs = oh.DataDirectory[:] + default: + return false, fmt.Errorf("unrecognized PE optional header type %T", f.OptionalHeader) + } + + if len(dirs) <= pe.IMAGE_DIRECTORY_ENTRY_SECURITY { + return false, nil + } + sec := dirs[pe.IMAGE_DIRECTORY_ENTRY_SECURITY] + return sec.VirtualAddress != 0 && sec.Size != 0, nil +} + // checkSDKProvenance reports the embedded SDK version and revision. func checkSDKProvenance(verbose bool) Check { return formatSDKProvenance(version.GetSDKProvenance(), verbose) @@ -977,6 +1064,12 @@ func buildDoctorBreadcrumbs(checks []Check) []output.Breadcrumb { Cmd: "basecamp skill install", Description: "Update installed skill", }) + case "Binary Signature": + breadcrumbs = append(breadcrumbs, output.Breadcrumb{ + Action: "upgrade", + Cmd: "basecamp upgrade", + Description: "Upgrade to a signed release", + }) case "Codex Plugin", "Codex Plugin Version": breadcrumbs = append(breadcrumbs, output.Breadcrumb{ Action: "setup_codex", diff --git a/internal/commands/doctor_test.go b/internal/commands/doctor_test.go index a37f29eec..b88ad0f9e 100644 --- a/internal/commands/doctor_test.go +++ b/internal/commands/doctor_test.go @@ -3,8 +3,11 @@ package commands import ( "bytes" "context" + "debug/pe" + "encoding/binary" "os" "path/filepath" + "runtime" "strings" "testing" @@ -529,6 +532,128 @@ func TestCheckLegacyInstall_NilWhenAlreadyMigrated(t *testing.T) { assert.Nil(t, check, "should return nil when .migrated marker exists") } +// writeMinimalPE synthesizes the smallest PE32+ file debug/pe can parse: +// DOS stub, PE signature, COFF header with zero sections, and a full +// 16-entry data directory. securityDirSet controls whether the +// IMAGE_DIRECTORY_ENTRY_SECURITY entry is populated (i.e. whether the file +// claims a WIN_CERTIFICATE / Authenticode overlay). +func writeMinimalPE(t *testing.T, securityDirSet bool) string { + t.Helper() + + var buf bytes.Buffer + + // DOS header: "MZ" magic, e_lfanew at 0x3C pointing at the PE signature. + dos := make([]byte, 64) + dos[0], dos[1] = 'M', 'Z' + binary.LittleEndian.PutUint32(dos[0x3C:], 64) + buf.Write(dos) + + buf.WriteString("PE\x00\x00") + + const optHeaderSize = 112 + 16*8 // PE32+ fixed fields + 16 data directories + + fh := make([]byte, 20) + binary.LittleEndian.PutUint16(fh[0:], 0x8664) // Machine: amd64 + binary.LittleEndian.PutUint16(fh[2:], 0) // NumberOfSections + binary.LittleEndian.PutUint16(fh[16:], optHeaderSize) // SizeOfOptionalHeader + buf.Write(fh) + + oh := make([]byte, optHeaderSize) + binary.LittleEndian.PutUint16(oh[0:], 0x20b) // PE32+ magic + binary.LittleEndian.PutUint32(oh[108:], 16) // NumberOfRvaAndSizes + if securityDirSet { + secOff := 112 + 8*pe.IMAGE_DIRECTORY_ENTRY_SECURITY + binary.LittleEndian.PutUint32(oh[secOff:], 0x2000) // file offset of WIN_CERTIFICATE + binary.LittleEndian.PutUint32(oh[secOff+4:], 8) // overlay size + } + buf.Write(oh) + + path := filepath.Join(t.TempDir(), "basecamp.exe") + require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644)) + return path +} + +func TestHasAuthenticodeSignature_Unsigned(t *testing.T) { + signed, err := hasAuthenticodeSignature(writeMinimalPE(t, false)) + require.NoError(t, err) + assert.False(t, signed) +} + +func TestHasAuthenticodeSignature_Signed(t *testing.T) { + signed, err := hasAuthenticodeSignature(writeMinimalPE(t, true)) + require.NoError(t, err) + assert.True(t, signed) +} + +func TestHasAuthenticodeSignature_NotPE(t *testing.T) { + path := filepath.Join(t.TempDir(), "not-a-pe") + require.NoError(t, os.WriteFile(path, []byte("plain text"), 0o644)) + + _, err := hasAuthenticodeSignature(path) + assert.Error(t, err) +} + +func TestSignatureCheck_PassAndWarn(t *testing.T) { + pass := signatureCheck(true) + assert.Equal(t, "Binary Signature", pass.Name) + assert.Equal(t, "pass", pass.Status) + assert.Equal(t, "Authenticode signature present", pass.Message) + assert.Empty(t, pass.Hint) + + warn := signatureCheck(false) + assert.Equal(t, "Binary Signature", warn.Name) + assert.Equal(t, "warn", warn.Status) + assert.Contains(t, warn.Message, "No Authenticode signature") + assert.Contains(t, warn.Hint, "Smart App Control") + assert.Contains(t, warn.Hint, "basecamp upgrade") +} + +func TestCheckBinarySignature_NilOffWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("exercises the off-Windows nil guard") + } + origVersion := version.Version + version.Version = "1.0.0" + defer func() { version.Version = origVersion }() + + assert.Nil(t, checkBinarySignature()) +} + +func TestBinarySignatureCheck_NilForDevBuild(t *testing.T) { + origVersion := version.Version + version.Version = "dev" + defer func() { version.Version = origVersion }() + + assert.Nil(t, binarySignatureCheck("windows")) +} + +func TestBinarySignatureCheck_InspectErrorWarns(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("relies on the test binary not being a PE file") + } + origVersion := version.Version + version.Version = "1.0.0" + defer func() { version.Version = origVersion }() + + // Forcing the windows path on a non-Windows host makes os.Executable + // return a non-PE binary, driving the inspect-error composition for real. + check := binarySignatureCheck("windows") + require.NotNil(t, check) + assert.Equal(t, "Binary Signature", check.Name) + assert.Equal(t, "warn", check.Status) + assert.Contains(t, check.Message, "Could not inspect executable") +} + +func TestBuildDoctorBreadcrumbs_BinarySignatureWarn(t *testing.T) { + checks := []Check{ + {Name: "Binary Signature", Status: "warn"}, + } + + breadcrumbs := buildDoctorBreadcrumbs(checks) + require.Len(t, breadcrumbs, 1) + assert.Equal(t, "basecamp upgrade", breadcrumbs[0].Cmd) +} + func TestCheckClaudeIntegration(t *testing.T) { // Claude registers via init() in the harness package. Its Checks function // calls harness.CheckClaudePlugin which reads the plugin file. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 61e274b37..3acfbde7e 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -143,6 +143,57 @@ function Verify-CosignSignature([string]$Version, [string]$BaseUrl, [string]$Tmp Info 'Signature verified' } +# Get-FirstRunFailureMessage diagnoses why running the freshly installed +# basecamp.exe failed. It best-effort probes the binary's Authenticode status +# and the Smart App Control state (releases up to v0.8.0-rc.1 ship an unsigned +# basecamp.exe, which Smart App Control blocks at process creation). Every +# branch leads with the original failure: diagnosis augments, never masks, +# the underlying error. PowerShell 5.1-compatible. +function Get-FirstRunFailureMessage([string]$Binary, [string]$Reason) { + $sigStatus = $null + try { + $sigStatus = (Get-AuthenticodeSignature -FilePath $Binary -ErrorAction Stop).Status + } catch { } + + # Smart App Control state: 0 = off, 1 = on, 2 = evaluation mode. + $sacState = $null + try { + $policy = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\CI\Policy' -ErrorAction Stop + $sacState = $policy.VerifiedAndReputablePolicyState + } catch { } + + $lead = "Installed basecamp.exe to $Binary, but running it failed: $Reason" + + if ("$sigStatus" -eq 'NotSigned' -and ($sacState -eq 1 -or $sacState -eq 2)) { + return @" +$lead + +This build of basecamp.exe is not code-signed, and Smart App Control is enabled. Smart App Control blocks unsigned executables no matter where they were downloaded from, and it has no per-app exceptions. Two options: + + 1. (Preferred) Install the Linux build inside WSL2 - Smart App Control does not apply there and your Windows security setup is untouched: + wsl --install + then, inside the WSL terminal: + curl -fsSL https://basecamp.com/install-cli | bash + + 2. Turn Smart App Control off (Windows Security > App & browser control > Smart App Control settings) and leave it off while using this unsigned build. Because there are no per-app exceptions, turning it back on re-blocks basecamp.exe on its next run - only re-enable after upgrading to a signed release. Windows 11 with the March/April 2026 updates can re-enable Smart App Control from Windows Security without a reset; on older builds re-enabling requires resetting Windows, so prefer the WSL2 option there. +"@ + } + + if ("$sigStatus" -eq 'NotSigned') { + return @" +$lead + +This build of basecamp.exe is not code-signed, and Windows Security or SmartScreen may have blocked or quarantined it. Check Windows Security > Protection history for a block or quarantine event, restore or allow basecamp.exe, then re-run the installer. +"@ + } + + return @" +$lead + +If Windows Security or antivirus interfered, check Windows Security > Protection history, restore or allow basecamp.exe, then re-run the installer. +"@ +} + function Get-PathEntries { param([string]$PathValue) @@ -301,7 +352,7 @@ function Main { $binaryPath = Join-Path $extractDir 'basecamp.exe' if (-not (Test-Path $binaryPath)) { - Fail 'basecamp.exe not found in archive' + Fail 'basecamp.exe not found in archive. If Windows Security or antivirus removed it during extraction, check Windows Security > Protection history, restore it, and re-run the installer.' } $installedBinary = Join-Path $BinDir 'basecamp.exe' @@ -312,12 +363,19 @@ function Main { try { Copy-Item -Force $binaryPath $installedBinary -ErrorAction Stop } catch { - Fail "Failed to install basecamp.exe. If it is in use, close any running 'basecamp' processes and re-run the installer. (Original error: $($_.Exception.Message))" + Fail "Failed to install basecamp.exe. If it is in use, close any running 'basecamp' processes and re-run the installer. If Windows Security quarantined it, check Windows Security > Protection history and restore it. (Original error: $($_.Exception.Message))" } Ensure-UserPath -Dir $BinDir Info "Installed basecamp to $installedBinary" - $installedVersion = & $installedBinary --version + # Smart App Control kills CreateProcess for unsigned executables, so the + # first run is where a block surfaces. Generic catch per the Copy-Item + # precedent above. + try { + $installedVersion = & $installedBinary --version + } catch { + Fail (Get-FirstRunFailureMessage -Binary $installedBinary -Reason $_.Exception.Message) + } Info "$installedVersion installed" $isInteractive = Test-InteractiveSession diff --git a/scripts/install.sh b/scripts/install.sh index e15730f44..ab50c977f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -318,13 +318,29 @@ verify_install() { binary_name="basecamp.exe" fi - local installed_version - if installed_version=$("$BIN_DIR/$binary_name" --version 2>/dev/null); then + local installed_version err_file + err_file=$(mktemp) + if installed_version=$("$BIN_DIR/$binary_name" --version 2>"$err_file"); then + rm -f "$err_file" info "$(green "${installed_version} installed")" return 0 fi - error "Installation failed - basecamp not working" + local run_error + run_error=$(cat "$err_file") + rm -f "$err_file" + + local detail="Installation failed - basecamp not working" + if [[ -n "$run_error" ]]; then + detail="$detail: $run_error" + fi + if [[ "$platform" == windows_* ]]; then + detail="$detail + Windows may have blocked the unsigned executable: Smart App Control only runs code-signed binaries. + Either install inside WSL2 (curl -fsSL https://basecamp.com/install-cli | bash) or see + https://github.com/basecamp/basecamp-cli#windows-smart-app-control-and-smartscreen" + fi + error "$detail" } setup_theme() { diff --git a/scripts/manage-release-env.sh b/scripts/manage-release-env.sh index 921a9e7bb..6e28a86d3 100755 --- a/scripts/manage-release-env.sh +++ b/scripts/manage-release-env.sh @@ -34,6 +34,9 @@ SECRETS_MANIFEST=( "basecamp/basecamp-cli|MACOS_NOTARY_KEY_ID|macOS Notarization/key-id" "basecamp/basecamp-cli|MACOS_NOTARY_ISSUER_ID|macOS Notarization/issuer-id" "basecamp/basecamp-cli|AUR_KEY|AUR SSH Key/private-key" + "basecamp/basecamp-cli|SM_API_KEY|DigiCert CodeSigning Cert/SM_API_KEY" + "basecamp/basecamp-cli|SM_CLIENT_CERT_FILE_B64|DigiCert CodeSigning Cert/SM_CLIENT_CERT_FILE_B64" + "basecamp/basecamp-cli|SM_CLIENT_CERT_PASSWORD|DigiCert CodeSigning Cert/SM_CLIENT_CERT_PASSWORD" "basecamp/hey-cli|RELEASE_APP_PRIVATE_KEY|Release GitHub App/private-key" "basecamp/hey-cli|MACOS_SIGN_P12|macOS Code Signing/p12-base64" "basecamp/hey-cli|MACOS_SIGN_PASSWORD|macOS Code Signing/password" diff --git a/scripts/sign-windows.sh b/scripts/sign-windows.sh new file mode 100755 index 000000000..f2437abe3 --- /dev/null +++ b/scripts/sign-windows.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# sign-windows.sh — Authenticode-sign a Windows artifact in place via +# DigiCert KeyLocker (cloud HSM) using jsign. +# +# Usage: sign-windows.sh TARGET PATH +# TARGET goreleaser build target (windows_amd64, windows_arm64) or the +# literal "windows" for non-goreleaser callers; anything else no-ops +# +# Contract: +# - non-windows target → exit 0, silent no-op +# - all SM_* signing env vars empty → exit 0 with a skip notice +# (forks, make test-release, local builds) +# - partial signing env → exit 1 (misconfiguration, not a skip) +# - signing or timestamping failure → nonzero (goreleaser aborts before +# publish — a release never silently +# ships unsigned) +# +# Required env when signing: SM_API_KEY, SM_CLIENT_CERT_FILE, +# SM_CLIENT_CERT_PASSWORD, SIGN_ALIAS, JSIGN_JAR. See RELEASING.md and +# bc3-desktop docs/windows-signing.md for the KeyLocker runbook. +set -euo pipefail + +target="${1:?usage: sign-windows.sh TARGET PATH}" +path="${2:?usage: sign-windows.sh TARGET PATH}" + +case "$target" in + windows|windows_*) ;; + *) exit 0 ;; +esac + +if [ -z "${SM_API_KEY:-}" ] && [ -z "${SM_CLIENT_CERT_FILE:-}" ] && [ -z "${SM_CLIENT_CERT_PASSWORD:-}" ]; then + echo "sign-windows: SM_* signing env not set — skipping Authenticode signing for $path" >&2 + exit 0 +fi + +for var in SM_API_KEY SM_CLIENT_CERT_FILE SM_CLIENT_CERT_PASSWORD SIGN_ALIAS JSIGN_JAR; do + if [ -z "${!var:-}" ]; then + echo "sign-windows: $var is empty while other signing env is set — refusing to continue" >&2 + exit 1 + fi +done + +[ -f "$SM_CLIENT_CERT_FILE" ] || { echo "sign-windows: client cert not found: $SM_CLIENT_CERT_FILE" >&2; exit 1; } +[ -f "$JSIGN_JAR" ] || { echo "sign-windows: jsign jar not found: $JSIGN_JAR" >&2; exit 1; } + +echo "sign-windows: signing $path via DigiCert KeyLocker" +java -jar "$JSIGN_JAR" \ + --storetype DIGICERTONE \ + --alias "$SIGN_ALIAS" \ + --storepass "${SM_API_KEY}|${SM_CLIENT_CERT_FILE}|${SM_CLIENT_CERT_PASSWORD}" \ + --tsaurl http://timestamp.digicert.com \ + --tsretries 3 \ + --tsretrywait 10 \ + "$path" From 6c33d4e71df3615f2be9b514e6940c4d769afd6f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 30 Jul 2026 17:07:26 -0700 Subject: [PATCH 2/2] Guard Windows signing preparation to the canonical repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fork, the secrets preflight is skipped but "Prepare Windows signing" still ran: decoding the empty SM_CLIENT_CERT_FILE_B64 secret exported a nonempty SM_CLIENT_CERT_FILE path, which the signing wrapper correctly treats as partial configuration and fails — breaking the fork/unsigned-release fallback. Guard the Java setup and preparation steps with the same repository check the preflight uses. Forks now export no SM_* signing env, so the wrapper takes its all-empty skip path and releases unsigned; canonical releases keep the fail-closed contract. --- .github/workflows/release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f86b14fd..0d385f01a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -248,12 +248,19 @@ jobs: SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} - name: Set up Java for jsign + if: github.repository == 'basecamp/basecamp-cli' uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: '21' + # Guarded like the preflight above: on forks this must not run at all — + # decoding an empty secret would export a nonempty SM_CLIENT_CERT_FILE, + # which the wrapper correctly treats as partial configuration and fails. + # With no SM_* env exported, forks take the wrapper's skip path instead + # and release unsigned. - name: Prepare Windows signing + if: github.repository == 'basecamp/basecamp-cli' env: SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} run: |