Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions docs/WINDOWS-BUILD-AND-SIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ The build, signing, and installer steps are all driven by **one PowerShell scrip
.\scripts\build-windows-production.ps1
```

Output: `release-windows\KeepKey-Vault-<version>-win-x64-setup.exe` (signed) and `SHA256SUMS-windows.txt`.
Output: `release-windows\KeepKey-Vault-<version>-win-x64-setup.zip` (contains the
EV-signed `setup.exe` + `setup-*.bin`) and `SHA256SUMS-windows.txt`. **Ship the
`.zip`, not a bare `.exe`** — see [Smart App Control](#smart-app-control-ship-a-zip-not-a-bare-exe).

To rebuild the installer from an existing build tree without rebuilding sources:

Expand Down Expand Up @@ -153,6 +155,42 @@ After the file exists once, subsequent builds reuse it. Commit-or-don't is a sep

---

## Smart App Control: ship a `.zip`, not a bare `.exe`

**This shipped broken in 1.4.11.** A normal single-file Inno installer
(`UseSetupLdr=yes`, the default) is a self-extractor: at runtime it unpacks an
**unsigned** `setup.tmp` engine into `%TEMP%\is-*` and executes it. **Smart App
Control (SAC)** and WDAC block unsigned code, so on any Windows 11 machine with
SAC on (the default on many clean installs) the installer dies immediately with
"Setup failed to initialize" (exit code 1) and logs **CodeIntegrity 3077/3033**
in `Microsoft-Windows-CodeIntegrity/Operational`. The outer `setup.exe` being
EV-signed does not help — SAC evaluates the extracted `setup.tmp` on its own, and
that file is never signed (stock Inno cannot sign it).

**The fix (already in `installer.iss` + `build-windows-production.ps1`):**
`UseSetupLdr=no`. With no loader there is no `setup.tmp`; the EV-signed `setup.exe`
IS the engine and runs in-process, so SAC only sees the signed exe and allows it.
Inno then emits `setup.exe` + `setup-*.bin`, which the build script zips into
`KeepKey-Vault-<version>-win-x64-setup.zip`. Users extract the zip and run
`setup.exe`. **Only the `.zip` is uploaded; never a bare `.exe`.**

**The app itself is SAC-clean** — every shipped binary (`KeepKeyVault.exe`,
`launcher.exe`, `bin\bun.exe`, DLLs) is EV-signed and runs under SAC without a
block. Only the installer's extracted `setup.tmp` was the problem.

> **You MUST test the installer on a machine with Smart App Control ON (Enforce)
> before every release.** SAC-off machines install fine even with the broken
> single-exe — that is exactly how 1.4.11 slipped through (the 1.4.7 smoke test
> ran on a SAC-off box). Verify no 3077/3033 appears:
> ```powershell
> Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-CodeIntegrity/Operational';Id=3077,3033;StartTime=(Get-Date).AddMinutes(-5)}
> # (also confirm SAC is actually on: (Get-MpComputerStatus).SmartAppControlState -eq 'On')
> ```

Full incident write-up: [`docs/incidents/1.4.11-smart-app-control-installer-block.md`](./incidents/1.4.11-smart-app-control-installer-block.md).

---

## Verifying the release

After the script finishes, verify everything before uploading:
Expand Down Expand Up @@ -265,9 +303,10 @@ Before tagging and uploading:
- [ ] Run `.\scripts\build-windows-production.ps1`
- [ ] **Non-AVX:** `KeepKeyVault.exe` disassembles to **0** AVX/VEX instructions (`.text` VSize ≈ `0x5A46`; `c5 f9 7f` absent) — see step 9
- [ ] **Non-AVX:** bundled `bun.exe` banner reads `Windows x64 (baseline)` and version ≥ 1.3.14 — see step 6
- [ ] Verify installer signature via `signtool verify /pa /v`
- [ ] Verify installer signature via `signtool verify /pa /v` (on the extracted `setup.exe`)
- [ ] **Smart App Control:** extract the `.zip` and run `setup.exe` on a machine with **SAC ON (Enforce)** — installs with **no** CodeIntegrity 3077/3033. This is mandatory and non-negotiable (see [Smart App Control](#smart-app-control-ship-a-zip-not-a-bare-exe)); SAC-off machines are NOT a valid test.
- [ ] Smoke-test the installer on a clean Windows VM
- [ ] **Non-AVX (if hardware available):** app launches past the splash on a no-AVX CPU (Gemini Lake N5030/N4020) instead of `0xC000001D`
- [ ] Compare `SHA256SUMS-windows.txt` against the installer hash
- [ ] Upload `*.exe` and `SHA256SUMS-windows.txt` to the GitHub draft release
- [ ] Compare `SHA256SUMS-windows.txt` against the `.zip` hash
- [ ] Upload the **`.zip`** (NOT a bare `.exe`) and `SHA256SUMS-windows.txt` to the GitHub release
- [ ] Run the installed app, pair a real device, confirm `vault-backend.log` has the expected boot lines
80 changes: 80 additions & 0 deletions docs/incidents/1.4.11-smart-app-control-installer-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Incident: 1.4.11 Windows installer blocked by Smart App Control

**Date:** 2026-07-05 → 2026-07-06
**Severity:** High — the Windows installer would not run for any user with Smart
App Control enabled (default on many clean Windows 11 installs).
**Status:** Fixed and re-shipped (`v1.4.11` now serves a `.zip`).

## Summary

The `v1.4.11` Windows installer (`KeepKey-Vault-1.4.11-win-x64-setup.exe`) failed
to launch on machines with **Smart App Control (SAC)** enabled. Double-clicking it
did nothing (or flashed an "Error" dialog); silent runs returned exit code 1 with
no Inno log ("Setup failed to initialize"). Nothing installed.

## Impact

- Any Windows 11 user with SAC on (a large and growing share — SAC is on by
default on many clean 24H2+ installs) could not install 1.4.11.
- Not 1.4.11-specific: **every** prior single-exe Inno release had the same latent
flaw. They "worked" only where SAC was off.

## Root cause

A default Inno Setup installer (`UseSetupLdr=yes`) is a self-extractor: at runtime
it unpacks an **unsigned** engine, `setup.tmp`, into `%TEMP%\is-*` and executes it.
Smart App Control / WDAC block unsigned code, so SAC killed `setup.tmp` before Inno
initialized.

- The **outer** `setup.exe` is EV-signed and Valid — but SAC evaluates the
extracted `setup.tmp` independently, and that file is never signed. Stock Inno
(verified on 6.7.3) has no way to sign it.
- Evidence: `Microsoft-Windows-CodeIntegrity/Operational` events **3077** and
**3033**:
> Code Integrity determined that a process (…\KeepKey-Vault-1.4.11-win-x64-setup.exe)
> attempted to load …\Temp\is-*.tmp\KeepKey-Vault-1.4.11-win-x64-setup.tmp that did
> not meet the Enterprise signing level requirements … (Policy ID: {0283ac0f-…}) — the SAC base policy.
- Confirmed the extracted `setup.tmp` is `NotSigned` (for 1.4.7 and 1.4.11 alike).
- Confirmed the **app itself is SAC-clean**: `KeepKeyVault.exe`, `launcher.exe`,
`bin\bun.exe`, and DLLs are all EV-signed and run under SAC with zero blocks.
Only the installer's extracted stub was the problem.

## Why it slipped through

The release smoke test (done on 1.4.7 earlier in the cycle) ran on a machine with
**SAC off**, where the broken single-exe installs fine. SAC was later turned on
(enforce) on the same machine, and 1.4.11 was the first release tested there — by
a user report ("downloaded, didn't open"), not by the release checklist. The
checklist had no "install under Smart App Control" gate.

## Fix

`UseSetupLdr=no` in `scripts/installer.iss`. With no loader there is no
`setup.tmp`; the EV-signed `setup.exe` is the engine and runs in-process, so SAC
only sees the signed exe and allows it. Inno then emits `setup.exe` + `setup-*.bin`,
which `scripts/build-windows-production.ps1` zips into
`KeepKey-Vault-<version>-win-x64-setup.zip`. Users extract the zip and run
`setup.exe`.

Validated on the SAC-Enforce machine (real download flow, zip marked with
Mark-of-the-Web, extracted, run): installer exit **0**, version → **1.4.11**,
**no** CodeIntegrity 3077/3033, app boots (`+869ms: boot complete`).

`v1.4.11` was re-shipped: uploaded the `.zip`, removed the SAC-blocked `.exe`.

## Prevention

- `docs/WINDOWS-BUILD-AND-SIGN.md`: new "Smart App Control" section + a **mandatory
"install under SAC (Enforce)"** item in the release checklist. SAC-off machines
are explicitly called out as an invalid test.
- `build-windows-production.ps1`: auto-zips the installer parts and deletes the
loose `.exe`/`.bin` so a bare single-exe can no longer be shipped by accident.

## Follow-ups

- **Download page:** the Windows link must point at the `.zip` (extract → run
`setup.exe`), not a `.exe`. (Outside this repo.)
- **Long-term single-file installer:** a `.zip` is a UX regression vs a one-click
`.exe`. NSIS runs entirely in-process (no extracted stub) and would give a single
SAC-clean signed `.exe`; MSIX/WiX-MSI are also SAC-friendly. Evaluate migrating
the installer so we can go back to a single downloadable file. Tracked separately.
30 changes: 27 additions & 3 deletions scripts/build-windows-production.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,30 @@ if (-not $SkipSign) {
}
}

# ============================================================================
# Package as .zip (Smart App Control-safe distribution)
# ============================================================================
# installer.iss sets UseSetupLdr=no, so Inno emits setup.exe + setup-*.bin
# instead of a single self-extracting exe. That is deliberate: the normal
# single-exe extracts an UNSIGNED setup.tmp engine to %TEMP% and runs it, which
# Smart App Control blocks ("failed to initialize", Code Integrity 3077/3033).
# With no loader there is no tmp -- SAC only sees the EV-signed setup.exe. Ship
# the parts zipped; the user extracts and runs setup.exe. See
# docs/WINDOWS-BUILD-AND-SIGN.md "Smart App Control".
Write-Step "Packaging installer as .zip (Smart App Control-safe)"
$installerParts = Get-ChildItem -Path $ArtifactsDir -File | Where-Object {
$_.Name -like "KeepKey-Vault-$Version-win-x64-setup*" -and ($_.Extension -eq '.exe' -or $_.Extension -eq '.bin')
}
if (-not $installerParts) { throw "No installer parts (setup.exe/.bin) found to package" }
$zipPath = Join-Path $ArtifactsDir "KeepKey-Vault-$Version-win-x64-setup.zip"
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Compress-Archive -Path $installerParts.FullName -DestinationPath $zipPath -CompressionLevel Optimal
# Drop the loose parts so ONLY the .zip is uploaded — a bare setup.exe won't run
# without its .bin siblings anyway, and leaving the SAC-blocked single exe around
# invites shipping the broken artifact.
$installerParts | Remove-Item -Force
Write-Success "Created: $(Split-Path $zipPath -Leaf) ($([math]::Round((Get-Item $zipPath).Length / 1MB, 1)) MB)"

# ============================================================================
# Generate Checksums
# ============================================================================
Expand Down Expand Up @@ -956,9 +980,9 @@ if (-not $SkipSign) {
}
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Yellow
Write-Host " 1. Test the installer: run the setup EXE" -ForegroundColor Gray
Write-Host " 2. Upload EXE to GitHub release" -ForegroundColor Gray
Write-Host " 3. Verify SmartScreen reputation" -ForegroundColor Gray
Write-Host " 1. Test on a Smart App Control (Enforce) machine: extract the .zip, run setup.exe" -ForegroundColor Gray
Write-Host " 2. Upload the .zip (NOT a bare .exe) to the GitHub release" -ForegroundColor Gray
Write-Host " 3. Verify SmartScreen/Smart App Control does not block it" -ForegroundColor Gray
} else {
Write-Host "WARNING: Artifacts are NOT signed - test build only" -ForegroundColor Yellow
}
Expand Down
6 changes: 6 additions & 0 deletions scripts/installer.iss
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ OutputBaseFilename=KeepKey-Vault-{#MyAppVersion}-win-x64-setup
SetupIconFile={#MySourceDir}\Resources\app-real.ico
Compression=lzma2
SolidCompression=yes
; Smart App Control / WDAC: a normal (UseSetupLdr=yes) single-exe installer extracts
; an UNSIGNED setup.tmp engine to %TEMP% and runs it; SAC blocks unsigned code, so the
; installer dies with "failed to initialize" (Code Integrity 3077/3033). UseSetupLdr=no
; makes the signed setup.exe the engine itself (no tmp extraction) so SAC only evaluates
; the EV-signed exe. Trade-off: output is setup.exe + setup-*.bin, shipped as a .zip.
UseSetupLdr=no
WizardStyle=modern
WizardImageFile={#MyScriptDir}\installer-wizard.bmp
WizardSmallImageFile={#MyScriptDir}\installer-small.bmp
Expand Down
Loading