Skip to content

Phase 2.4: pin, verify and fail closed in the VIIPER setup script - #16

Merged
potpiemuncher merged 1 commit into
mainfrom
phase2/installer-hardening
Jul 27, 2026
Merged

Phase 2.4: pin, verify and fail closed in the VIIPER setup script#16
potpiemuncher merged 1 commit into
mainfrom
phase2/installer-hardening

Conversation

@potpiemuncher

Copy link
Copy Markdown
Owner

Plan task 2.4, written on top of the upstream merge analysed in docs/dev/upstream-delta-2026-07-26.md. Closes #12; finishes #8.

Nothing was installed, upgraded, removed or modified on the development machine. No driver, service, scheduled task, registry Run value or VIIPER install directory was touched. The setup script was never executed — it was parsed (AST only), and the decision layer it consults was exercised read-only against artefacts already retained in the workspace.

The problem

The bundled script downloaded a kernel-driver installer and ran it with /S on the next line, with no digest and no signature check. It admitted any usbip-win2 at or above a floor — and the floor was compared against the FileVersion of usbip2_ude.sys, a DriverVer such as 1.45.29.368 that is not the release label and compares greater than every floor anyone would write. It checked one of the two driver packages after install. It created two autostart entries. It started backends with the upstream self-updater live.

Where the decisions went, and why

Into C#, with the script keeping the mechanical half — fetch bytes, run an installer, swap a file.

The deciding argument is not that C# is nicer to test. It is that the admission rule is the manifest decides, the manifest is ViiperDriverManifest, and that type's own contract says it must not be duplicated across the UI, the broker or the installer. A PowerShell copy of the version table would have been exactly that duplicate — and it would have been the copy deciding whether a kernel driver gets installed.

New file What it owns
ViiperInstallerPins.cs The two exact artefacts setup may fetch: URL, SHA-256, size, whether Authenticode is required and from whom, and how the digest was obtained.
ViiperInstallerPolicy.cs Pure, total decisions. Each returns its audit lines together with its verdict, from the same call, so the log and the outcome cannot disagree.
ViiperInstallerPolicyCommand.cs The read-only -viiperinstallerpolicy verb surface the script consults.
PendingApplicationRestart.cs Issue #12's ordering, enforced rather than commented.

Results travel through a --out file rather than stdout. This is a WPF (GUI-subsystem) process: whether its console output reaches a caller's pipe depends on how the caller launched it, and a verification result that sometimes arrives is not a verification result.

Requirement by requirement

1. Pinned release + digest + Authenticode before execution. Get-VerifiedPinnedFile is the only way an artefact reaches disk, and its next statement is the verification call. A refusal deletes the file and throws. Verified live against the genuine signed installer:

verdict=Approved
log=SHA-256: expected 51620FA5...F185FEA, actual 51620FA5...F185FEA.
log=Authenticode chain: expected trusted under normal Windows policy, actual trusted (trusted).
log=Authenticode signer: expected "Cloudyne Systems (Scheibling Consulting AB)", actual "Cloudyne Systems (Scheibling Consulting AB)".

and against a copy with one byte flipped:

verdict=DigestMismatch
log=SHA-256: expected 51620FA5...F185FEA, actual D8A1BF04...B5692F.
log=Decision: Verification failed: USBip-0.9.7.7-x64.exe does not have the pinned SHA-256. The file is discarded and nothing is run from it.

2. Post-install validation of the actual package pair. A validate-installed verb runs ViiperDriverValidationCommand.RunDiagnostic() — the same implementation and the same 0/1/2 exit codes that -viiperdriverdiagnostic runs — and the script branches on it. Both packages, catalog trust, manifest match.

Deliberately not launched as a second -viiperdriverdiagnostic process, which the task suggested: that switch prints to an attached parent console and, when there is none, opens a modal report window. A setup step that can block on a dialog nobody can see is not a verification step. Exit code 2 and "could not run at all" — including the app executable not being next to the script — are both failures.

3. No silent acceptance of an unlisted release. The floor is gone. The primary input is the gate's four-state answer, not a file version. Verified live on the maintainer's machine, which has 0.9.7.8:

readiness=ValidatedExperimental
matchedrelease=0.9.7.8
action=LeaveRecognisedReleaseAlone
summary=usbip-win2 0.9.7.8 is installed. It is a release this build recognises as an
        experimental baseline, and it is left exactly as it is.

An unlisted release — newer or older — is refused, and setup touches nothing: no install over it, no downgrade, no "repair". VIIPER is pinned to an exact asset; Get-GithubReleaseAsset and the newest-non-draft walk are deleted.

4. Atomic install with retained rollback. .previous is no longer deleted on success, and its path is logged. Rollback that exists only inside the install window is not rollback: the failure it guards against is a backend that installs cleanly and then misbehaves.

5. Log every decision. Expected beside actual, for every check, in evaluation order, into install.log.

6. Both autostart mechanisms removed. viiper.exe install and Register-ViiperRunTask are gone, and with them the $registrationSafeToRun dance they were load-bearing for. Upstream's Stop-ViiperProcesses survives verbatim — retry, escalate, fail closed — now needed only by the atomic install. A pre-existing entry is detected through 2.4b's read-only detector, reported, and removed only with -RemoveViiperAutostart or the Settings button. Never adopted.

7. Issue #8, the remaining half. Start-AndVerifyViiper takes its argument vector from ViiperBackendSpawn.ServerArguments via the pins verb rather than spelling out server, so the script cannot drift from the application, and it sets VIIPER_UPDATE_NOTIFY too. With both autostart entries gone, no path is left that starts an update-nagging backend.

8. Issue #12. RestartApplication no longer starts anything; it records intent. CleanShutdown starts the replacement after threadComEvent.Close(), and PendingApplicationRestart.Launch refuses until MarkSingleInstanceReleased() has been called. The ordering is a precondition, not a comment — an edit that moves the launch earlier fails a test that says why.

The backend across the restart is deliberately not special-cased. Stop-on-exit runs as usual, the owned backend goes down with the app, and the new instance starts a fresh one on demand. Exempting an install-driven restart would leave a backend running that the new instance does not own and would therefore never stop, turning a temporary special case into a permanent orphan.

The VIIPER pin, and how its digest was obtained

hbashton/VIIPER v0.0.5, asset viiper.exe, 11,255,296 bytes, SHA-256 3AD872D006DF2FC282E381A68B5A5B3C51E4DA3614D250AB3FDA1C272EF745D0.

Two independent sources agree: the digest computed locally from the copy downloaded during the 2026-07-25 audit, and the digest GitHub reports for that same release asset. Upstream publishes it unsigned, so RequireAuthenticode is false and the pin says so in as many words rather than skipping a check quietly — the digest is the whole identity.

The asset is also mis-stamped: it reports v0.0.3-18-g02fffe6 as its own version because the release workflow built it without fetching tags (hbashton/VIIPER#3, ours, fixes that). The pin records the mis-stamp explicitly so nothing ever validates by the version the file claims.

Testing approach

The brief offered (a) dot-sourceable script functions plus Pester in CI, or (b) decisions in testable C# with the script as thin orchestration, and asked for whichever puts the fail-closed logic under real tests.

(b). The manifest argument above is decisive. Beyond it: MSTest already gates every merge here, so a Pester job would be a second harness, a second runner dependency and a second place a filter can go stale — bought for logic that would still have to reach into C# for the version table. And the shape of (b) is what makes these properties testable at all: DecideDownloadVerification is a function from observed facts to a verdict, so "valid signature, unexpected subject" is three lines of test instead of a signing fixture.

+67 tests, 696 to 763, 0 failed.

  • ViiperInstallerPolicyTests (46) — correct digest; wrong digest; missing file; null observation; uncomputable digest; valid signature with an unexpected subject; untrusted signature; absent signature; a signature that was never evaluated (the failure shape that reads exactly like a pass); unsigned component approved on digest alone and refused on a wrong one; version not in the manifest; version newer than pinned; already-installed pinned version; already-installed recognised-but-different version; registered-but-not-bound in four flavours; unknown enum value; post-install 0/1/2, an undocumented code, and never-started; every exit-code mapping; the autostart plan including an unreadable state.
  • PendingApplicationRestartTests (10) — the Post-install auto-restart cannot restart, and can leave neither app nor backend running #12 ordering, including the assertion that the starter is never invoked while the handle is held.
  • ViiperInstallerScriptTests (11) — the weakest tests here, and labelled as such in the file. Matching text in a script proves the text is there, not that the script behaves. They exist for four properties that are properties of absent code — no autostart creation, no backend start without the update flag, no URL or digest outside the pins, no deletion of the rollback backup — where a regression is silent: the script keeps working, it just stops being safe.

Also verified: the script parses clean ([Parser]::ParseFile, 0 errors, 2,609 tokens), and every policy verb was run read-only against the real machine and the real artefacts.

Deviations

  1. Post-install validation goes through validate-installed rather than a second -viiperdriverdiagnostic process — same implementation, same exit codes, no modal-dialog hazard.
  2. VIIPER pinned to the public v0.0.5 asset rather than a bundled copy. The plan allowed bundling until Prevent stale release artifacts hbashton/VIIPER#3 lands; unnecessary, because pinning by digest already immunises us against the mis-stamp. Bundling stays available for Phase 5.2 via -ViiperBackendFile.
  3. -UsbipInstallerFile / -ViiperBackendFile added, so the VM run sheet's negative cases travel the real code path. Not a bypass: a staged file replaces the download and nothing else, verified by the same call against the same pin.
  4. A third exit code (3) — "installed, but the pair cannot be validated until Windows restarts" — because that is neither success nor failure, and collapsing it into either would mean lying in one direction.
  5. The script end to end is unverified and cannot be verified here: running it installs a kernel driver, which Part 3 rule 1 puts behind a TESTENV checkpoint.

For the VM pass

PHASE2-VM-VALIDATION-PREP-20260726.md Phase B needs no change, and gains a cheaper route: B1 and B2 can be done without installing anything by calling verify-file directly — that is the exact call the script gates on. To exercise them through the script instead, pass -UsbipInstallerFile <staged>.

The bundled setup script downloaded a kernel-driver installer and ran it
with /S on the next line, admitted any usbip-win2 release at or above a
floor, checked one of the two driver packages after install, created two
autostart entries nobody wanted, and started backends with the upstream
self-updater live. This changes all of that.

The decisions moved into C#; the script kept the mechanical half. Not for
testing convenience: the admission rule is "the manifest decides", the
manifest is ViiperDriverManifest, and its own contract says it must not be
duplicated into the UI, the broker or the installer. A PowerShell copy of
the version table would have been that duplicate, and it would have been
the copy deciding whether a kernel driver gets installed.

  * ViiperInstallerPins       - the two exact artefacts setup may fetch:
                                URL, SHA-256, size, required signer, and
                                how the digest was obtained.
  * ViiperInstallerPolicy     - pure decisions: download verdict, usbip
                                install action, post-install verdict, exit
                                codes, autostart plan. Each returns its
                                audit lines with its verdict.
  * ViiperInstallerPolicyCommand - the read-only -viiperinstallerpolicy
                                verb surface the script consults.
  * PendingApplicationRestart - issue #12's ordering, enforced.

Nothing is executed before its digest and, where the publisher signs, its
Authenticode chain and signer have matched a pin. The version floor is
gone: the input is now the driver gate's four-state answer, so an
installed 0.9.7.8 is recognised, reported as the experimental baseline it
is, and left alone rather than downgraded. The package pair Windows
actually bound is validated after the driver step through the same
implementation as -viiperdriverdiagnostic. The .previous backup survives a
successful install. Both autostart mechanisms are gone, and a pre-existing
one is reported and removed only when asked, never adopted.

Closes #12: the replacement instance is started by the shutdown path after
the single-instance handle is closed, and Launch refuses until it has
been. The owned backend still stops on exit and the new instance starts a
fresh one on demand - exempting the restart would leave an orphan the new
instance could never stop.

Finishes #8: the script's own verification start takes its argument vector
from ViiperBackendSpawn, so it cannot drift from the application, and with
both autostart entries gone no path is left that starts an update-nagging
backend.

763 tests pass (+67), 0 failed. The script itself is [VM]-gated and was
parsed, not run.
@potpiemuncher
potpiemuncher merged commit eafe042 into main Jul 27, 2026
4 checks passed
@potpiemuncher
potpiemuncher deleted the phase2/installer-hardening branch July 27, 2026 01:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Post-install auto-restart cannot restart, and can leave neither app nor backend running

1 participant