Phase 2.4: pin, verify and fail closed in the VIIPER setup script - #16
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
/Son 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 ofusbip2_ude.sys, a DriverVer such as1.45.29.368that 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.ViiperInstallerPins.csViiperInstallerPolicy.csViiperInstallerPolicyCommand.cs-viiperinstallerpolicyverb surface the script consults.PendingApplicationRestart.csResults travel through a
--outfile 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-VerifiedPinnedFileis 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:and against a copy with one byte flipped:
2. Post-install validation of the actual package pair. A
validate-installedverb runsViiperDriverValidationCommand.RunDiagnostic()— the same implementation and the same 0/1/2 exit codes that-viiperdriverdiagnosticruns — and the script branches on it. Both packages, catalog trust, manifest match.Deliberately not launched as a second
-viiperdriverdiagnosticprocess, 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:
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-GithubReleaseAssetand the newest-non-draft walk are deleted.4. Atomic install with retained rollback.
.previousis 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 installandRegister-ViiperRunTaskare gone, and with them the$registrationSafeToRundance they were load-bearing for. Upstream'sStop-ViiperProcessessurvives 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-RemoveViiperAutostartor the Settings button. Never adopted.7. Issue #8, the remaining half.
Start-AndVerifyViipertakes its argument vector fromViiperBackendSpawn.ServerArgumentsvia thepinsverb rather than spelling outserver, so the script cannot drift from the application, and it setsVIIPER_UPDATE_NOTIFYtoo. With both autostart entries gone, no path is left that starts an update-nagging backend.8. Issue #12.
RestartApplicationno longer starts anything; it records intent.CleanShutdownstarts the replacement afterthreadComEvent.Close(), andPendingApplicationRestart.Launchrefuses untilMarkSingleInstanceReleased()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/VIIPERv0.0.5, assetviiper.exe, 11,255,296 bytes, SHA-2563AD872D006DF2FC282E381A68B5A5B3C51E4DA3614D250AB3FDA1C272EF745D0.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
RequireAuthenticodeis 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-g02fffe6as 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:
DecideDownloadVerificationis 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
validate-installedrather than a second-viiperdriverdiagnosticprocess — same implementation, same exit codes, no modal-dialog hazard.-ViiperBackendFile.-UsbipInstallerFile/-ViiperBackendFileadded, 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.For the VM pass
PHASE2-VM-VALIDATION-PREP-20260726.mdPhase B needs no change, and gains a cheaper route: B1 and B2 can be done without installing anything by callingverify-filedirectly — that is the exact call the script gates on. To exercise them through the script instead, pass-UsbipInstallerFile <staged>.