From 35cba66dc61a9d85240d44d5e9a2519fd471f9db Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 15 Aug 2026 13:01:40 -0500 Subject: [PATCH 1/4] test: bind native latency superiority evidence --- _testing/e2e/cmd/verifylatency/main.go | 45 +- _testing/e2e/cmd/verifylatencymatrix/main.go | 328 +++++++++++++++ _testing/e2e/latency/profile_contract_test.go | 42 +- _testing/e2e/latency/report.go | 302 +++++++++++--- _testing/e2e/latency/report_test.go | 156 ++++++- _testing/e2e/latency/superiority.go | 384 ++++++++++++++++++ _testing/e2e/latency/superiority_test.go | 222 ++++++++++ _testing/e2e/latency/trace_markers.go | 61 ++- _testing/e2e/latency/trace_markers_test.go | 29 +- _testing/e2e/latency_gate_windows_test.go | 161 ++++++-- .../scripts/Invoke-ViiperE2ELatencyGate.ps1 | 280 ++++++++++++- .../scripts/Invoke-ViiperE2ELatencyMatrix.ps1 | 145 ++++++- docs/testing/e2e_latency.md | 84 ++-- 13 files changed, 2076 insertions(+), 163 deletions(-) create mode 100644 _testing/e2e/cmd/verifylatencymatrix/main.go create mode 100644 _testing/e2e/latency/superiority.go create mode 100644 _testing/e2e/latency/superiority_test.go diff --git a/_testing/e2e/cmd/verifylatency/main.go b/_testing/e2e/cmd/verifylatency/main.go index 7c2a916c..6b65367b 100644 --- a/_testing/e2e/cmd/verifylatency/main.go +++ b/_testing/e2e/cmd/verifylatency/main.go @@ -11,10 +11,13 @@ import ( ) func main() { - var input, markersPath, source, sdlRevision, sdlHash, manifestHash, driverHash, driverBuildIdentity, profileHash string - var samples int + var input, markersPath, tracePath, source, sdlRevision, sdlHash, manifestHash, driverHash, driverBuildIdentity, profileHash string + var usbipRuntimeHash, packageValidationMode, localTestCertificateSHA string + var orientation, cycleID string + var samples, cycleIndex, cycleCount int flag.StringVar(&input, "input", "", "latency suite JSON") flag.StringVar(&markersPath, "markers", "", "decoded ETL TraceLogging marker JSON") + flag.StringVar(&tracePath, "trace", "", "raw sequential ETL bound by marker JSON") flag.StringVar(&source, "source", "", "expected repository revision") flag.StringVar(&sdlRevision, "sdl-revision", "", "expected SDL revision") flag.StringVar(&sdlHash, "sdl-sha256", "", "expected loaded SDL SHA-256") @@ -22,12 +25,26 @@ func main() { flag.StringVar(&driverHash, "driver-sha256", "", "expected installed driver SHA-256") flag.StringVar(&driverBuildIdentity, "driver-build-identity", "", "expected negotiated loaded-driver identity") flag.StringVar(&profileHash, "trace-profile-sha256", "", "expected WPRP SHA-256") + flag.StringVar(&usbipRuntimeHash, "usbip-runtime-sha256", "", "expected exact USB/IP runtime provenance SHA-256") + flag.StringVar(&packageValidationMode, "package-validation-mode", "", "expected production or local-test package gate") + flag.StringVar(&localTestCertificateSHA, "local-test-certificate-sha256", "", "expected local-test certificate SHA-256") + flag.StringVar(&orientation, "orientation", "", "expected balanced schedule orientation") + flag.StringVar(&cycleID, "cycle-id", "", "expected balanced matrix cycle ID") + flag.IntVar(&cycleIndex, "cycle-index", 0, "expected balanced matrix cycle index") + flag.IntVar(&cycleCount, "cycle-count", 0, "expected balanced matrix cycle count") flag.IntVar(&samples, "samples", 0, "expected sample pairs per controller/transport") flag.Parse() - if input == "" || markersPath == "" || source == "" || sdlRevision == "" || sdlHash == "" || - manifestHash == "" || driverHash == "" || driverBuildIdentity == "" || profileHash == "" || samples == 0 { + if input == "" || markersPath == "" || tracePath == "" || source == "" || sdlRevision == "" || sdlHash == "" || + manifestHash == "" || driverHash == "" || driverBuildIdentity == "" || profileHash == "" || usbipRuntimeHash == "" || + packageValidationMode == "" || localTestCertificateSHA == "" || orientation == "" || + cycleID == "" || cycleIndex == 0 || cycleCount == 0 || samples == 0 { fail(errors.New("all verifier flags are required")) } + expectedLocalTestCertificateSHA := strings.ToLower(localTestCertificateSHA) + if strings.ToLower(packageValidationMode) == latency.PackageValidationProduction && + expectedLocalTestCertificateSHA == "none" { + expectedLocalTestCertificateSHA = "" + } file, err := os.Open(input) if err != nil { fail(err) @@ -45,8 +62,11 @@ func main() { p.SDLSourceRevision != strings.ToLower(sdlRevision) || p.SDLBinarySHA256 != strings.ToLower(sdlHash) || p.NativePackageManifestSHA256 != strings.ToLower(manifestHash) || + p.NativePackageValidationMode != strings.ToLower(packageValidationMode) || + p.NativeLocalTestCertificateSHA256 != expectedLocalTestCertificateSHA || p.NativeDriverSHA256 != strings.ToLower(driverHash) || p.NativeDriverBuildIdentity != strings.ToLower(driverBuildIdentity) || + p.USBIPRuntime.CaptureSHA256 != strings.ToLower(usbipRuntimeHash) || p.TraceProfileSHA256 != strings.ToLower(profileHash) || p.TraceProviderName != latency.TraceProviderName || p.TraceProviderGUID != latency.TraceProviderGUID || @@ -55,9 +75,13 @@ func main() { fail(errors.New("suite provenance does not match the production invocation")) } for _, controllerCase := range suite.Cases { - if controllerCase.Workload.SamplePairs != samples { - fail(fmt.Errorf("%s has %d sample pairs, want %d", - controllerCase.Workload.ControllerType, controllerCase.Workload.SamplePairs, samples)) + workload := controllerCase.Workload + if workload.SamplePairs != samples || + workload.ScheduleOrientation != strings.ToLower(orientation) || + workload.CycleID != strings.ToLower(cycleID) || + workload.CycleIndex != cycleIndex || workload.CycleCount != cycleCount { + fail(fmt.Errorf("%s workload is not bound to the requested sample and balanced-cycle identity", + workload.ControllerType)) } } markersFile, err := os.Open(markersPath) @@ -65,11 +89,14 @@ func main() { fail(err) } defer markersFile.Close() - markers, err := latency.ParseTraceMarkers(markersFile) + markerEvidence, err := latency.ParseTraceMarkerEvidence(markersFile) if err != nil { fail(err) } - if err = latency.VerifyTraceMarkers(suite, markers); err != nil { + if err = latency.VerifyTraceMarkerSource(markerEvidence, tracePath); err != nil { + fail(err) + } + if err = latency.VerifyTraceMarkers(suite, markerEvidence.Markers); err != nil { fail(err) } fmt.Printf("strictly verified %d controller cases\n", len(suite.Cases)) diff --git a/_testing/e2e/cmd/verifylatencymatrix/main.go b/_testing/e2e/cmd/verifylatencymatrix/main.go new file mode 100644 index 00000000..ef84e4ad --- /dev/null +++ b/_testing/e2e/cmd/verifylatencymatrix/main.go @@ -0,0 +1,328 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/Alia5/VIIPER/_testing/e2e/latency" +) + +const ( + matrixSchema = "viiper.controller-to-game.latency-priority-matrix/v2" + evidenceSchema = "viiper.controller-to-game.latency-superiority-evidence/v1" +) + +var ( + hashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + cycleIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) +) + +type evidenceFile struct { + Path string `json:"path"` + Length int64 `json:"length"` + SHA256 string `json:"sha256"` +} + +type matrixRun struct { + PriorityClass string `json:"priority_class"` + PriorityCycle int `json:"priority_cycle"` + CycleIndex int `json:"cycle_index"` + Orientation string `json:"orientation"` + Report evidenceFile `json:"report"` + Trace evidenceFile `json:"trace"` + Markers evidenceFile `json:"decoded_markers"` +} + +type matrixManifest struct { + Schema string `json:"schema"` + GeneratedAt time.Time `json:"generated_at"` + SourceRevision string `json:"source_revision"` + NativePackageManifestSHA256 string `json:"native_package_manifest_sha256"` + NativePackageValidationMode string `json:"native_package_validation_mode"` + NativeLocalTestCertificateSHA256 string `json:"native_local_test_certificate_sha256,omitempty"` + NativeDriverSHA256 string `json:"native_driver_sha256"` + NativeDriverBuildIdentity string `json:"native_driver_build_identity"` + CycleID string `json:"cycle_id"` + CycleCount int `json:"cycle_count"` + CyclesPerPriority int `json:"cycles_per_priority"` + SamplePairs int `json:"sample_pairs_per_transition"` + Runs []matrixRun `json:"runs"` +} + +type evidenceOutput struct { + Schema string `json:"schema"` + GeneratedAt time.Time `json:"generated_at"` + Matrix evidenceFile `json:"matrix"` + Analysis *latency.SuperiorityReport `json:"analysis"` + Verdict string `json:"verdict"` +} + +func main() { + var input, output, source string + flag.StringVar(&input, "input", "", "priority matrix JSON") + flag.StringVar(&output, "output", "", "exclusive superiority evidence JSON") + flag.StringVar(&source, "source", "", "expected repository revision") + flag.Parse() + if input == "" || output == "" || source == "" { + fail(errors.New("input, output, and source are required")) + } + if !filepath.IsAbs(input) || !filepath.IsAbs(output) { + fail(errors.New("matrix input and superiority output paths must be absolute")) + } + if _, err := os.Lstat(output); !errors.Is(err, os.ErrNotExist) { + if err == nil { + fail(fmt.Errorf("refusing to overwrite %s", output)) + } + fail(err) + } + + matrixIdentity, err := verifyEvidence(evidenceFileFromPath(input)) + if err != nil { + fail(err) + } + file, err := os.Open(matrixIdentity.Path) + if err != nil { + fail(err) + } + matrix, err := parseMatrix(file) + closeErr := file.Close() + if err != nil { + fail(err) + } + if closeErr != nil { + fail(closeErr) + } + cycles, err := verifyMatrix(matrix, strings.ToLower(source)) + if err != nil { + fail(err) + } + + generatedAt := time.Now().UTC() + analysis, err := latency.AnalyzeSuperiority(cycles, generatedAt) + if err != nil { + fail(err) + } + envelope := evidenceOutput{ + Schema: evidenceSchema, GeneratedAt: generatedAt, + Matrix: matrixIdentity, Analysis: analysis, Verdict: analysis.Verdict, + } + if err = writeExclusive(output, &envelope); err != nil { + fail(err) + } + if err = latency.RequireSuperiority(analysis); err != nil { + fail(fmt.Errorf("superiority evidence retained at %s: %w", output, err)) + } + fmt.Printf("strictly verified native latency was lower in all %d observed balanced cycles for this exact machine session\n", + len(cycles)) +} + +func parseMatrix(reader io.Reader) (*matrixManifest, error) { + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + var matrix matrixManifest + if err := decoder.Decode(&matrix); err != nil { + return nil, fmt.Errorf("decode priority matrix: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return nil, errors.New("priority matrix contains trailing JSON") + } + return nil, fmt.Errorf("decode trailing priority matrix data: %w", err) + } + return &matrix, nil +} + +func verifyMatrix(matrix *matrixManifest, source string) ([]latency.SuperiorityCycle, error) { + if matrix.Schema != matrixSchema || matrix.GeneratedAt.IsZero() || + matrix.SourceRevision != source || !cycleIDPattern.MatchString(matrix.CycleID) || + !hashPattern.MatchString(matrix.NativePackageManifestSHA256) || + !hashPattern.MatchString(matrix.NativeDriverSHA256) || + !hashPattern.MatchString(matrix.NativeDriverBuildIdentity) || + (matrix.NativePackageValidationMode != latency.PackageValidationProduction && + matrix.NativePackageValidationMode != latency.PackageValidationLocalTest) || + (matrix.NativePackageValidationMode == latency.PackageValidationProduction && + matrix.NativeLocalTestCertificateSHA256 != "") || + (matrix.NativePackageValidationMode == latency.PackageValidationLocalTest && + !hashPattern.MatchString(matrix.NativeLocalTestCertificateSHA256)) || + matrix.CycleCount != len(matrix.Runs) || matrix.CycleCount%2 != 0 || + matrix.CyclesPerPriority < latency.SuperiorityMinimumCycles || + matrix.CyclesPerPriority%2 != 0 || + matrix.CycleCount != 2*matrix.CyclesPerPriority || + matrix.SamplePairs < latency.MinimumProductionSamplePairs || + matrix.SamplePairs > latency.MaximumProductionSamplePairs { + return nil, errors.New("priority matrix header is incomplete or not production-strength") + } + seenPaths := make(map[string]bool, len(matrix.Runs)*3) + cycles := make([]latency.SuperiorityCycle, 0, len(matrix.Runs)) + for runIndex, run := range matrix.Runs { + cycleIndex := runIndex + 1 + wantPriority := "normal" + wantPriorityCycle := cycleIndex + if cycleIndex > matrix.CyclesPerPriority { + wantPriority = "high" + wantPriorityCycle -= matrix.CyclesPerPriority + } + wantOrientation := latency.ScheduleOrientationForCycle(cycleIndex) + if run.PriorityClass != wantPriority || run.PriorityCycle != wantPriorityCycle || + run.CycleIndex != cycleIndex || run.Orientation != wantOrientation { + return nil, fmt.Errorf("matrix run %d is not in canonical balanced order", cycleIndex) + } + verifiedFiles := make(map[string]evidenceFile, 3) + for label, identity := range map[string]evidenceFile{ + "report": run.Report, "trace": run.Trace, "markers": run.Markers, + } { + verified, err := verifyEvidence(identity) + if err != nil { + return nil, fmt.Errorf("cycle %d %s: %w", cycleIndex, label, err) + } + canonical := strings.ToLower(verified.Path) + if seenPaths[canonical] { + return nil, fmt.Errorf("cycle %d reuses evidence path %s", cycleIndex, verified.Path) + } + seenPaths[canonical] = true + verifiedFiles[label] = verified + } + reportFile, err := os.Open(run.Report.Path) + if err != nil { + return nil, err + } + suite, parseErr := latency.ParseSuiteReport(reportFile) + closeErr := reportFile.Close() + if parseErr != nil { + return nil, fmt.Errorf("cycle %d report: %w", cycleIndex, parseErr) + } + if closeErr != nil { + return nil, closeErr + } + if err = latency.RequireSuitePass(suite); err != nil { + return nil, fmt.Errorf("cycle %d report: %w", cycleIndex, err) + } + markerFile, err := os.Open(run.Markers.Path) + if err != nil { + return nil, err + } + markerEvidence, markerErr := latency.ParseTraceMarkerEvidence(markerFile) + markerCloseErr := markerFile.Close() + if markerErr != nil { + return nil, fmt.Errorf("cycle %d markers: %w", cycleIndex, markerErr) + } + if markerCloseErr != nil { + return nil, markerCloseErr + } + if markerEvidence.SourceTraceLength != verifiedFiles["trace"].Length || + markerEvidence.SourceTraceSHA256 != verifiedFiles["trace"].SHA256 { + return nil, fmt.Errorf("cycle %d decoded markers are not bound to its raw ETL", cycleIndex) + } + if err = latency.VerifyTraceMarkers(suite, markerEvidence.Markers); err != nil { + return nil, fmt.Errorf("cycle %d markers do not bind its report: %w", cycleIndex, err) + } + if len(suite.Cases) != 3 { + return nil, fmt.Errorf("cycle %d controller set is incomplete", cycleIndex) + } + workload := suite.Cases[0].Workload + if suite.Provenance.SourceRevision != source || + suite.Provenance.NativePackageManifestSHA256 != matrix.NativePackageManifestSHA256 || + suite.Provenance.NativePackageValidationMode != matrix.NativePackageValidationMode || + suite.Provenance.NativeLocalTestCertificateSHA256 != matrix.NativeLocalTestCertificateSHA256 || + suite.Provenance.NativeDriverSHA256 != matrix.NativeDriverSHA256 || + suite.Provenance.NativeDriverBuildIdentity != matrix.NativeDriverBuildIdentity || + suite.Provenance.Machine.ProcessPriorityClass != wantPriority || + workload.CycleID != matrix.CycleID || workload.CycleIndex != cycleIndex || + workload.CycleCount != matrix.CycleCount || + workload.ScheduleOrientation != wantOrientation || + workload.SamplePairs != matrix.SamplePairs { + return nil, fmt.Errorf("cycle %d report contradicts its matrix receipt", cycleIndex) + } + cycles = append(cycles, latency.SuperiorityCycle{Priority: wantPriority, Suite: suite}) + } + return cycles, nil +} + +func evidenceFileFromPath(path string) evidenceFile { + info, err := os.Lstat(path) + if err != nil { + return evidenceFile{Path: path} + } + digest, err := fileSHA256(path) + if err != nil { + return evidenceFile{Path: path} + } + return evidenceFile{Path: path, Length: info.Size(), SHA256: digest} +} + +func verifyEvidence(identity evidenceFile) (evidenceFile, error) { + if !filepath.IsAbs(identity.Path) || identity.Length <= 0 || + !hashPattern.MatchString(identity.SHA256) { + return evidenceFile{}, errors.New("evidence identity is incomplete or noncanonical") + } + info, err := os.Lstat(identity.Path) + if err != nil { + return evidenceFile{}, err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() != identity.Length { + return evidenceFile{}, errors.New("evidence is not the exact nonempty regular file") + } + digest, err := fileSHA256(identity.Path) + if err != nil { + return evidenceFile{}, err + } + if digest != identity.SHA256 { + return evidenceFile{}, fmt.Errorf("evidence SHA-256 %s does not match %s", digest, identity.SHA256) + } + canonical, err := filepath.Abs(identity.Path) + if err != nil { + return evidenceFile{}, err + } + identity.Path = filepath.Clean(canonical) + return identity, nil +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + hash := sha256.New() + if _, err = io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func writeExclusive(path string, value any) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + data = append(data, '\n') + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + writeErr := error(nil) + if _, writeErr = file.Write(data); writeErr == nil { + writeErr = file.Sync() + } + closeErr := file.Close() + if writeErr != nil { + return writeErr + } + return closeErr +} + +func fail(err error) { + fmt.Fprintln(os.Stderr, "latency matrix rejected:", err) + os.Exit(1) +} diff --git a/_testing/e2e/latency/profile_contract_test.go b/_testing/e2e/latency/profile_contract_test.go index 672ce4b2..70cea757 100644 --- a/_testing/e2e/latency/profile_contract_test.go +++ b/_testing/e2e/latency/profile_contract_test.go @@ -38,11 +38,18 @@ func TestProductionTraceAndWrapperFailClosedContract(t *testing.T) { "github.com/Alia5/VIIPER/internal/transport/udecx.nativeSourceRevision=$headRevision", "Get-WinEvent -FilterHashtable", "ProviderName = 'VIIPER-LatencyGate'", "trace_marker_id", "start_qpc_ticks", "trace_marker_qpc_ticks", + "latency-trace-markers/v1", "source_trace_length", "source_trace_sha256", + "-trace $trace", "Win32_PnPEntity", "@($_.HardwareID) -contains 'ROOT\\VIIPER\\UDE'", "$ownedRootDevices[0].PNPDeviceID", "Dropped\\s+Event", "Buffers?\\s+Lost", "Resolve-ExactExecutablePath", "[Environment]::SystemDirectory", "VIIPER_E2E_EXPECTED_PRIORITY_CLASS", "git_executable_sha256", + "Get-ExactUSBIPRuntimeProvenance", "VIIPER_E2E_USBIP_RUNTIME_PROVENANCE", + "VIIPER_E2E_USBIP_RUNTIME_PROVENANCE_SHA256", + "[string]$PackageValidationMode = 'Production'", "LocalTestCertificatePath", + "VIIPER_E2E_PACKAGE_VALIDATION_MODE", "VIIPER_E2E_LOCAL_TEST_CERTIFICATE_SHA256", + "viiper.controller-to-game.latency-suite/v3", "-buildvcs=false", } { if !strings.Contains(wrapperText, want) { @@ -79,14 +86,30 @@ func TestProductionTraceAndWrapperFailClosedContract(t *testing.T) { t.Fatal(err) } matrixText := string(matrix) + normalizedMatrixText := strings.ReplaceAll(matrixText, "\r\n", "\n") for _, want := range []string{ - "priority = 'Normal'", "priority = 'High'", "Get-ExactEvidenceFile", - "latency-priority-matrix/v1", "process_priority_class", "Flush($true)", + "foreach ($priority in @('Normal', 'High'))", "CyclesPerPriority", + "orientation = $orientation", "cycle_index = $cycleIndex", + "Get-ExactEvidenceFile", "latency-priority-matrix/v2", + "process_priority_class", "Flush($true)", "verifylatencymatrix", + "native_package_validation_mode = $matrixPackageIdentity.native_package_validation_mode", + "native_local_test_certificate_sha256 = $matrixPackageIdentity.native_local_test_certificate_sha256", + "native_package_manifest_sha256 = $matrixPackageIdentity.native_package_manifest_sha256", + "every balanced cycle for this exact machine session", } { if !strings.Contains(matrixText, want) { t.Fatalf("priority-matrix wrapper is missing fail-closed contract %q", want) } } + certificateBinding := strings.Index(matrixText, "$common.LocalTestCertificatePath = $LocalTestCertificatePath") + firstLiveCycle := strings.Index(matrixText, "& $gate @common") + if certificateBinding < 0 || firstLiveCycle < 0 || certificateBinding > firstLiveCycle { + t.Fatal("local-test certificate is not bound before the first expensive live matrix cycle") + } + if !strings.Contains(normalizedMatrixText, "$repository = if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) {") || + !strings.Contains(normalizedMatrixText, "else {\n (Resolve-Path -LiteralPath $RepositoryRoot") { + t.Fatal("matrix repository default/explicit selection is not a single fail-closed if/else") + } verifier, err := os.ReadFile("../cmd/verifylatency/main.go") if err != nil { t.Fatal(err) @@ -95,4 +118,19 @@ func TestProductionTraceAndWrapperFailClosedContract(t *testing.T) { !strings.Contains(string(verifier), "latency.RequireSuitePass") { t.Fatal("production verifier no longer invokes strict parsing and pass enforcement") } + matrixVerifier, err := os.ReadFile("../cmd/verifylatencymatrix/main.go") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "latency.ParseSuiteReport", "latency.ParseTraceMarkerEvidence", + "latency.VerifyTraceMarkers", "latency.AnalyzeSuperiority", + "suite.Provenance.NativePackageValidationMode != matrix.NativePackageValidationMode", + "suite.Provenance.NativePackageManifestSHA256 != matrix.NativePackageManifestSHA256", + "all %d observed balanced cycles for this exact machine session", + } { + if !strings.Contains(string(matrixVerifier), want) { + t.Fatalf("matrix verifier is missing strict evidence contract %q", want) + } + } } diff --git a/_testing/e2e/latency/report.go b/_testing/e2e/latency/report.go index 7ee0729b..ff6bb8f9 100644 --- a/_testing/e2e/latency/report.go +++ b/_testing/e2e/latency/report.go @@ -1,7 +1,9 @@ package latency import ( + "bytes" "crypto/sha256" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -27,15 +29,20 @@ var productionPhaseSweepOffsetsNS = [...]int64{ } const ( - SchemaV2 = "viiper.controller-to-game.latency/v2" - SuiteSchemaV2 = "viiper.controller-to-game.latency-suite/v2" + SchemaV3 = "viiper.controller-to-game.latency/v3" + SuiteSchemaV3 = "viiper.controller-to-game.latency-suite/v3" TransportUSBIP = "usbip" TransportNativeUDE = "native-ude" AuthenticationMode = "password-authenticated-encrypted-stream" TraceProviderName = "VIIPER-LatencyGate" TraceProviderGUID = "{e1726ef8-c2e6-4dad-bbf7-2d871b953ab1}" - USBIPBaselineMode = "version-probed-functional-baseline-not-source-bound" + USBIPBaselineMode = "exact-installed-usbip-win2-runtime-and-source-bound-server" USBIPBaselineVersion = "0.9.7.7" + USBIPRuntimeSchemaV1 = "viiper.usbip-win2.runtime-provenance/v1" + PackageValidationProduction = "production" + PackageValidationLocalTest = "local-test" + ScheduleOrientationABBA = "abba" + ScheduleOrientationBAAB = "baab" MinimumProductionSamplePairs = 256 MaximumProductionSamplePairs = 10_000 ProductionWarmupPairs = 16 @@ -68,21 +75,44 @@ type BlockSpec struct { SamplePairs int } -// ProductionBlockSchedule splits the declared samples as evenly as possible -// across USB/IP, native UDE, native UDE, then USB/IP. The ABBA order controls -// first/last-run drift without discarding the identity proof for either block. +// ProductionBlockSchedule preserves the original ABBA schedule for callers +// which do not yet select an orientation explicitly. func ProductionBlockSchedule(samplePairs int) []BlockSpec { + return ProductionBlockScheduleForOrientation(samplePairs, + ScheduleOrientationABBA) +} + +// ScheduleOrientationForCycle deterministically alternates the two balanced +// orders. An even cycle count therefore contains the same number of ABBA and +// BAAB runs, and a report cannot relabel an observed order after collection. +func ScheduleOrientationForCycle(cycleIndex int) string { + if cycleIndex%2 == 0 { + return ScheduleOrientationBAAB + } + return ScheduleOrientationABBA +} + +// ProductionBlockScheduleForOrientation splits the declared samples as evenly +// as possible across two blocks per transport. ABBA and BAAB are paired in the +// production superiority matrix so first/last-run and nonlinear carryover do +// not always favor the same transport. +func ProductionBlockScheduleForOrientation(samplePairs int, + orientation string) []BlockSpec { firstBlockPairs := samplePairs / ProductionTransportBlocks secondBlockPairs := samplePairs - firstBlockPairs secondFirstSequence := firstBlockPairs + 1 + first, second := TransportUSBIP, TransportNativeUDE + if orientation == ScheduleOrientationBAAB { + first, second = TransportNativeUDE, TransportUSBIP + } return []BlockSpec{ - {Order: 1, Transport: TransportUSBIP, TransportBlock: 1, + {Order: 1, Transport: first, TransportBlock: 1, FirstSequence: 1, SamplePairs: firstBlockPairs}, - {Order: 2, Transport: TransportNativeUDE, TransportBlock: 1, + {Order: 2, Transport: second, TransportBlock: 1, FirstSequence: 1, SamplePairs: firstBlockPairs}, - {Order: 3, Transport: TransportNativeUDE, TransportBlock: 2, + {Order: 3, Transport: second, TransportBlock: 2, FirstSequence: secondFirstSequence, SamplePairs: secondBlockPairs}, - {Order: 4, Transport: TransportUSBIP, TransportBlock: 2, + {Order: 4, Transport: first, TransportBlock: 2, FirstSequence: secondFirstSequence, SamplePairs: secondBlockPairs}, } } @@ -239,6 +269,10 @@ type Workload struct { PhaseSweepOffsetsNS []int64 `json:"phase_sweep_offsets_ns"` PhaseSweepSHA256 string `json:"phase_sweep_sha256"` Authentication string `json:"authentication"` + ScheduleOrientation string `json:"schedule_orientation"` + CycleID string `json:"cycle_id"` + CycleIndex int `json:"cycle_index"` + CycleCount int `json:"cycle_count"` } type MachineProvenance struct { @@ -252,36 +286,82 @@ type MachineProvenance struct { ProcessElevated bool `json:"process_elevated"` } +type USBIPFileIdentity struct { + Path string `json:"path"` + Length int64 `json:"length"` + SHA256 string `json:"sha256"` + FileVersion string `json:"file_version,omitempty"` + ProductVersion string `json:"product_version,omitempty"` + SignatureStatus string `json:"signature_status,omitempty"` + SignerSubject string `json:"signer_subject,omitempty"` + SignerThumbprint string `json:"signer_thumbprint,omitempty"` +} + +type USBIPServiceProvenance struct { + Name string `json:"name"` + Start uint32 `json:"start"` + Type uint32 `json:"type"` + PublishedINFName string `json:"published_inf_name"` + Image USBIPFileIdentity `json:"image"` + INF USBIPFileIdentity `json:"inf"` + PublishedINF USBIPFileIdentity `json:"published_inf"` + Catalog USBIPFileIdentity `json:"catalog"` +} + +type USBIPRootControllerProvenance struct { + InstanceID string `json:"instance_id"` + HardwareIDs []string `json:"hardware_ids"` + Service string `json:"service"` + Provider string `json:"provider"` + DriverVersion string `json:"driver_version"` + PublishedINF string `json:"published_inf"` + Signer string `json:"signer"` + IsSigned bool `json:"is_signed"` +} + +type USBIPRuntimeProvenance struct { + Schema string `json:"schema"` + CaptureSHA256 string `json:"capture_sha256,omitempty"` + CaptureBase64 string `json:"capture_base64,omitempty"` + Services []USBIPServiceProvenance `json:"services"` + RootControllers []USBIPRootControllerProvenance `json:"root_controllers"` +} + type Provenance struct { - SourceRevision string `json:"source_revision"` - SDLSourceRevision string `json:"sdl_source_revision"` - SDLBinaryPath string `json:"sdl_binary_path"` - SDLBinarySHA256 string `json:"sdl_binary_sha256"` - NativePackageManifestSHA256 string `json:"native_package_manifest_sha256"` - NativeDriverSHA256 string `json:"native_driver_sha256"` - NativeDriverBuildIdentity string `json:"native_driver_build_identity"` - QPCFrequency int64 `json:"qpc_frequency"` - TraceProviderName string `json:"trace_provider_name"` - TraceProviderGUID string `json:"trace_provider_guid"` - TraceProfileSHA256 string `json:"trace_profile_sha256"` - USBIPBaselineMode string `json:"usbip_baseline_mode"` - USBIPBaselineVersion string `json:"usbip_baseline_version"` - GoVersion string `json:"go_version"` - GOOS string `json:"goos"` - GOARCH string `json:"goarch"` - GitExecutablePath string `json:"git_executable_path"` - GitExecutableSHA256 string `json:"git_executable_sha256"` - GoExecutablePath string `json:"go_executable_path"` - GoExecutableSHA256 string `json:"go_executable_sha256"` - WPRExecutablePath string `json:"wpr_executable_path"` - WPRExecutableSHA256 string `json:"wpr_executable_sha256"` - Machine MachineProvenance `json:"machine"` + SourceRevision string `json:"source_revision"` + SDLSourceRevision string `json:"sdl_source_revision"` + SDLBinaryPath string `json:"sdl_binary_path"` + SDLBinarySHA256 string `json:"sdl_binary_sha256"` + NativePackageManifestSHA256 string `json:"native_package_manifest_sha256"` + NativePackageValidationMode string `json:"native_package_validation_mode"` + NativeLocalTestCertificateSHA256 string `json:"native_local_test_certificate_sha256,omitempty"` + NativeDriverSHA256 string `json:"native_driver_sha256"` + NativeDriverBuildIdentity string `json:"native_driver_build_identity"` + QPCFrequency int64 `json:"qpc_frequency"` + TraceProviderName string `json:"trace_provider_name"` + TraceProviderGUID string `json:"trace_provider_guid"` + TraceProfileSHA256 string `json:"trace_profile_sha256"` + USBIPBaselineMode string `json:"usbip_baseline_mode"` + USBIPBaselineVersion string `json:"usbip_baseline_version"` + USBIPRuntime USBIPRuntimeProvenance `json:"usbip_runtime"` + GoVersion string `json:"go_version"` + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + GitExecutablePath string `json:"git_executable_path"` + GitExecutableSHA256 string `json:"git_executable_sha256"` + GoExecutablePath string `json:"go_executable_path"` + GoExecutableSHA256 string `json:"go_executable_sha256"` + WPRExecutablePath string `json:"wpr_executable_path"` + WPRExecutableSHA256 string `json:"wpr_executable_sha256"` + Machine MachineProvenance `json:"machine"` } // SampleMarkerID is the canonical cross-artifact identity shared by JSON and // the TraceLogging marker emitted after the SDL edge is observed. -func SampleMarkerID(controller, transport string, block, sequence int, transition Transition) string { - return fmt.Sprintf("%s:%s:%d:%d:%s", controller, transport, block, sequence, transition) +func SampleMarkerID(cycleID string, cycleIndex int, controller, transport string, + block, sequence int, transition Transition) string { + return fmt.Sprintf("%s:%d:%s:%s:%d:%d:%s", cycleID, cycleIndex, + controller, transport, block, sequence, transition) } // QPCIntervalNS converts a bounded raw QueryPerformanceCounter interval to @@ -367,9 +447,12 @@ type SuiteReport struct { } var ( - revisionPattern = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`) - hashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) - containerPattern = regexp.MustCompile( + revisionPattern = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`) + hashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + thumbprintPattern = regexp.MustCompile(`^[0-9a-f]{40}$`) + cycleIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) + publishedINFPattern = regexp.MustCompile(`^oem[0-9]+\.inf$`) + containerPattern = regexp.MustCompile( `(?i)^\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}$`) ) @@ -616,7 +699,7 @@ func compareMetric(usbip, native float64) MetricComparison { } func validateBase(report *Report) error { - if report.Schema != SchemaV2 { + if report.Schema != SchemaV3 { return fmt.Errorf("unsupported report schema %q", report.Schema) } if report.GeneratedAt.IsZero() { @@ -637,6 +720,12 @@ func validateBase(report *Report) error { !hashPattern.MatchString(report.Provenance.NativeDriverBuildIdentity) { return errors.New("source-bound native package manifest and installed driver hashes are required") } + if (report.Provenance.NativePackageValidationMode != PackageValidationProduction || + report.Provenance.NativeLocalTestCertificateSHA256 != "") && + (report.Provenance.NativePackageValidationMode != PackageValidationLocalTest || + !hashPattern.MatchString(report.Provenance.NativeLocalTestCertificateSHA256)) { + return errors.New("native package validation mode or local-test certificate identity is invalid") + } if report.Provenance.QPCFrequency <= 0 || report.Provenance.TraceProviderName != TraceProviderName || report.Provenance.TraceProviderGUID != TraceProviderGUID || @@ -645,7 +734,10 @@ func validateBase(report *Report) error { } if report.Provenance.USBIPBaselineMode != USBIPBaselineMode || report.Provenance.USBIPBaselineVersion != USBIPBaselineVersion { - return errors.New("USB/IP comparison must be explicitly labeled as the exact version-probed, non-source-bound baseline") + return errors.New("USB/IP comparison is not the exact installed comparator") + } + if err := ValidateUSBIPRuntimeProvenance(report.Provenance.USBIPRuntime); err != nil { + return err } if report.Provenance.GoVersion == "" || report.Provenance.GOOS != "windows" || report.Provenance.GOARCH == "" { @@ -672,6 +764,16 @@ func validateBase(report *Report) error { report.Workload.Authentication != AuthenticationMode { return errors.New("workload identity is incomplete or unsupported") } + if (report.Workload.ScheduleOrientation != ScheduleOrientationABBA && + report.Workload.ScheduleOrientation != ScheduleOrientationBAAB) || + !cycleIDPattern.MatchString(report.Workload.CycleID) || + report.Workload.CycleCount < 2 || report.Workload.CycleCount%2 != 0 || + report.Workload.CycleIndex < 1 || + report.Workload.CycleIndex > report.Workload.CycleCount || + report.Workload.ScheduleOrientation != + ScheduleOrientationForCycle(report.Workload.CycleIndex) { + return errors.New("balanced schedule orientation and canonical cycle identity are required") + } if err := validateControllerWorkload(report.Workload); err != nil { return err } @@ -705,9 +807,10 @@ func validateBase(report *Report) error { report.Policy.NativeMaxOverUSBIPNS > DefaultNativeMaxOverUSBIPNS { return errors.New("native latency policy is absent or weaker than the reviewed release limits") } - schedule := ProductionBlockSchedule(report.Workload.SamplePairs) + schedule := ProductionBlockScheduleForOrientation( + report.Workload.SamplePairs, report.Workload.ScheduleOrientation) if len(report.Runs) != len(schedule) { - return errors.New("report must contain exactly the four ABBA transport blocks") + return errors.New("report must contain exactly four balanced transport blocks") } for index := range report.Runs { @@ -723,7 +826,7 @@ func validateBase(report *Report) error { if len(run.Samples) != 0 { firstTimestamp := run.Samples[0].EventTimestampNS if priorEventTimestamp != 0 && firstTimestamp < priorEventTimestamp { - return errors.New("SDL event clock regressed between ABBA transport blocks") + return errors.New("SDL event clock regressed between balanced transport blocks") } priorEventTimestamp = run.Samples[len(run.Samples)-1].EventTimestampNS } @@ -743,7 +846,7 @@ func validateRun(run *Run, workload Workload, provenance Provenance, block Block if run.Order != block.Order || run.Transport != block.Transport || run.TransportBlock != block.TransportBlock || run.FirstSequence != block.FirstSequence || run.SamplePairs != block.SamplePairs { - return fmt.Errorf("block metadata does not match the production ABBA schedule: %+v", block) + return fmt.Errorf("block metadata does not match the production balanced schedule: %+v", block) } if run.Authentication != AuthenticationMode { return errors.New("API/controller stream is not authenticated identically") @@ -767,8 +870,9 @@ func validateRun(run *Run, workload Workload, provenance Provenance, block Block return fmt.Errorf("sample %d is %d/%s, want %d/%s", index, sample.Sequence, sample.Transition, wantSequence, wantTransition) } - wantMarkerID := SampleMarkerID(workload.ControllerType, run.Transport, - run.TransportBlock, sample.Sequence, sample.Transition) + wantMarkerID := SampleMarkerID(workload.CycleID, workload.CycleIndex, + workload.ControllerType, run.Transport, run.TransportBlock, + sample.Sequence, sample.Transition) qpcLatencyNS, qpcErr := QPCIntervalNS(sample.StartQPCTicks, sample.EndQPCTicks, provenance.QPCFrequency) if qpcErr != nil || sample.LatencyNS != qpcLatencyNS || sample.EventTimestampNS == 0 || @@ -923,6 +1027,106 @@ func containsFold(values []string, want string) bool { return false } +func ValidateUSBIPRuntimeProvenance(proof USBIPRuntimeProvenance) error { + if proof.Schema != USBIPRuntimeSchemaV1 || !hashPattern.MatchString(proof.CaptureSHA256) { + return errors.New("USB/IP runtime provenance schema or capture hash is invalid") + } + rawCapture, err := base64.StdEncoding.DecodeString(proof.CaptureBase64) + if err != nil || len(rawCapture) == 0 || len(rawCapture) > 64*1024 || + base64.StdEncoding.EncodeToString(rawCapture) != proof.CaptureBase64 { + return errors.New("USB/IP runtime provenance has no canonical bounded raw capture") + } + digest := sha256.Sum256(rawCapture) + if fmt.Sprintf("%x", digest[:]) != proof.CaptureSHA256 { + return errors.New("USB/IP runtime raw capture does not match its SHA-256") + } + decoder := json.NewDecoder(bytes.NewReader(rawCapture)) + decoder.DisallowUnknownFields() + var captured USBIPRuntimeProvenance + if err = decoder.Decode(&captured); err != nil { + return fmt.Errorf("decode USB/IP raw capture: %w", err) + } + var trailing any + if err = decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return errors.New("USB/IP raw capture contains trailing JSON") + } + if captured.CaptureSHA256 != "" || captured.CaptureBase64 != "" || + captured.Schema != proof.Schema || + !reflect.DeepEqual(captured.Services, proof.Services) || + !reflect.DeepEqual(captured.RootControllers, proof.RootControllers) { + return errors.New("USB/IP structured provenance does not exactly match its raw capture") + } + expectedServices := []string{"usbip2_filter", "usbip2_ude"} + if len(proof.Services) != len(expectedServices) { + return fmt.Errorf("USB/IP runtime has %d exact driver services, want %d", + len(proof.Services), len(expectedServices)) + } + servicePublishedINF := make(map[string]string, len(proof.Services)) + for index, service := range proof.Services { + if service.Name != expectedServices[index] || service.Type != 1 || service.Start > 4 || + !publishedINFPattern.MatchString(service.PublishedINFName) { + return fmt.Errorf("USB/IP service %d registry identity is invalid", index) + } + if err := validateUSBIPFileIdentity(service.Image, true); err != nil { + return fmt.Errorf("USB/IP %s image: %w", service.Name, err) + } + if err := validateUSBIPFileIdentity(service.Catalog, true); err != nil { + return fmt.Errorf("USB/IP %s catalog: %w", service.Name, err) + } + if err := validateUSBIPFileIdentity(service.INF, false); err != nil { + return fmt.Errorf("USB/IP %s INF: %w", service.Name, err) + } + if err := validateUSBIPFileIdentity(service.PublishedINF, false); err != nil { + return fmt.Errorf("USB/IP %s published INF: %w", service.Name, err) + } + if service.INF.SHA256 != service.PublishedINF.SHA256 || + service.Image.SignerThumbprint != service.Catalog.SignerThumbprint || + !strings.Contains(strings.ToLower(service.Image.SignerSubject), "microsoft") || + !strings.Contains(strings.ToLower(service.Catalog.SignerSubject), "microsoft") { + return fmt.Errorf("USB/IP %s package bytes or Microsoft signature identity disagree", service.Name) + } + servicePublishedINF[service.Name] = service.PublishedINFName + } + if len(proof.RootControllers) < 1 || len(proof.RootControllers) > 16 { + return fmt.Errorf("USB/IP runtime has %d root controllers", len(proof.RootControllers)) + } + seenRoots := make(map[string]bool, len(proof.RootControllers)) + priorInstance := "" + for index, controller := range proof.RootControllers { + canonicalInstance := strings.ToUpper(controller.InstanceID) + if canonicalInstance == "" || seenRoots[canonicalInstance] || + (index != 0 && canonicalInstance <= priorInstance) || + !strings.HasPrefix(canonicalInstance, `ROOT\USB\`) || + !containsFold(controller.HardwareIDs, `ROOT\USBIP_WIN2\UDE`) || + !strings.EqualFold(controller.Service, "usbip2_ude") || + !strings.EqualFold(controller.Provider, "USBIP-WIN2") || + controller.DriverVersion == "" || + !strings.EqualFold(controller.PublishedINF, servicePublishedINF["usbip2_ude"]) || + !controller.IsSigned || !strings.Contains(strings.ToLower(controller.Signer), "microsoft") { + return fmt.Errorf("USB/IP root controller %d identity is invalid or ambiguous", index) + } + seenRoots[canonicalInstance] = true + priorInstance = canonicalInstance + } + return nil +} + +func validateUSBIPFileIdentity(identity USBIPFileIdentity, signed bool) error { + if identity.Path == "" || identity.Length <= 0 || !hashPattern.MatchString(identity.SHA256) { + return errors.New("path, length, or SHA-256 is invalid") + } + if signed { + if identity.SignatureStatus != "Valid" || identity.SignerSubject == "" || + !thumbprintPattern.MatchString(identity.SignerThumbprint) { + return errors.New("valid Authenticode signer identity is required") + } + } else if identity.SignatureStatus != "" || identity.SignerSubject != "" || + identity.SignerThumbprint != "" { + return errors.New("unsigned byte identity contains contradictory signature fields") + } + return nil +} + func expectedSDLRealType(controllerType string) int32 { switch controllerType { case "xbox360": @@ -1017,7 +1221,7 @@ func FinalizeSuite(suite *SuiteReport) error { if suite == nil { return errors.New("nil latency suite") } - if suite.Schema != SuiteSchemaV2 { + if suite.Schema != SuiteSchemaV3 { return fmt.Errorf("unsupported latency suite schema %q", suite.Schema) } if suite.GeneratedAt.IsZero() { @@ -1087,6 +1291,10 @@ func sameWorkloadPolicy(left, right *Report) bool { reflect.DeepEqual(left.Workload.PhaseSweepOffsetsNS, right.Workload.PhaseSweepOffsetsNS) && left.Workload.PhaseSweepSHA256 == right.Workload.PhaseSweepSHA256 && left.Workload.Authentication == right.Workload.Authentication && + left.Workload.ScheduleOrientation == right.Workload.ScheduleOrientation && + left.Workload.CycleID == right.Workload.CycleID && + left.Workload.CycleIndex == right.Workload.CycleIndex && + left.Workload.CycleCount == right.Workload.CycleCount && reflect.DeepEqual(left.Policy, right.Policy) } diff --git a/_testing/e2e/latency/report_test.go b/_testing/e2e/latency/report_test.go index 1ec3979c..9773f439 100644 --- a/_testing/e2e/latency/report_test.go +++ b/_testing/e2e/latency/report_test.go @@ -2,6 +2,9 @@ package latency import ( "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "math" @@ -118,6 +121,53 @@ func TestFinalizeRequiresExactToolExecutableProvenance(t *testing.T) { } } +func TestFinalizeSeparatesProductionAndLocalTestPackageEvidence(t *testing.T) { + local := validReport(t) + local.Provenance.NativePackageValidationMode = PackageValidationLocalTest + local.Provenance.NativeLocalTestCertificateSHA256 = strings.Repeat("9", 64) + if err := Finalize(local); err != nil { + t.Fatalf("exact local-test evidence was rejected: %v", err) + } + + contradictory := validReport(t) + contradictory.Provenance.NativeLocalTestCertificateSHA256 = strings.Repeat("9", 64) + if err := Finalize(contradictory); err == nil || + !strings.Contains(err.Error(), "validation mode") { + t.Fatalf("production evidence with a local-test signer error=%v", err) + } +} + +func TestUSBIPRuntimeProvenanceIsBoundToItsExactRawCapture(t *testing.T) { + proof := validUSBIPRuntimeProvenance() + if err := ValidateUSBIPRuntimeProvenance(proof); err != nil { + t.Fatal(err) + } + + t.Run("structured mutation", func(t *testing.T) { + mutated := proof + mutated.Services = append([]USBIPServiceProvenance(nil), proof.Services...) + mutated.Services[1].Image.SHA256 = strings.Repeat("d", 64) + if err := ValidateUSBIPRuntimeProvenance(mutated); err == nil || + !strings.Contains(err.Error(), "raw capture") { + t.Fatalf("structured USB/IP mutation error=%v", err) + } + }) + + t.Run("raw mutation", func(t *testing.T) { + mutated := proof + raw, err := base64.StdEncoding.DecodeString(mutated.CaptureBase64) + if err != nil { + t.Fatal(err) + } + raw[len(raw)-2] ^= 1 + mutated.CaptureBase64 = base64.StdEncoding.EncodeToString(raw) + if err = ValidateUSBIPRuntimeProvenance(mutated); err == nil || + !strings.Contains(err.Error(), "SHA-256") { + t.Fatalf("raw USB/IP mutation error=%v", err) + } + }) +} + func TestUSBIPAnchorUsesINFHardwareIDAndOSAssignedInstance(t *testing.T) { // usbip-win2's INF binds ROOT\USBIP_WIN2\UDE to usbip2_ude, while live // SetupAPI/pnputil evidence exposes the present OS-assigned instance as @@ -148,14 +198,31 @@ func TestUSBIPAnchorUsesINFHardwareIDAndOSAssignedInstance(t *testing.T) { } func TestProductionBlockScheduleIsCounterbalancedAndComplete(t *testing.T) { - want := []BlockSpec{ + wantABBA := []BlockSpec{ {Order: 1, Transport: TransportUSBIP, TransportBlock: 1, FirstSequence: 1, SamplePairs: 128}, {Order: 2, Transport: TransportNativeUDE, TransportBlock: 1, FirstSequence: 1, SamplePairs: 128}, {Order: 3, Transport: TransportNativeUDE, TransportBlock: 2, FirstSequence: 129, SamplePairs: 129}, {Order: 4, Transport: TransportUSBIP, TransportBlock: 2, FirstSequence: 129, SamplePairs: 129}, } - if got := ProductionBlockSchedule(257); !reflect.DeepEqual(got, want) { - t.Fatalf("schedule=%+v want %+v", got, want) + if got := ProductionBlockSchedule(257); !reflect.DeepEqual(got, wantABBA) { + t.Fatalf("ABBA schedule=%+v want %+v", got, wantABBA) + } + wantBAAB := []BlockSpec{ + {Order: 1, Transport: TransportNativeUDE, TransportBlock: 1, FirstSequence: 1, SamplePairs: 128}, + {Order: 2, Transport: TransportUSBIP, TransportBlock: 1, FirstSequence: 1, SamplePairs: 128}, + {Order: 3, Transport: TransportUSBIP, TransportBlock: 2, FirstSequence: 129, SamplePairs: 129}, + {Order: 4, Transport: TransportNativeUDE, TransportBlock: 2, FirstSequence: 129, SamplePairs: 129}, + } + if got := ProductionBlockScheduleForOrientation(257, ScheduleOrientationBAAB); !reflect.DeepEqual(got, wantBAAB) { + t.Fatalf("BAAB schedule=%+v want %+v", got, wantBAAB) + } + for cycleIndex, want := range []string{ + ScheduleOrientationABBA, ScheduleOrientationBAAB, + ScheduleOrientationABBA, ScheduleOrientationBAAB, + } { + if got := ScheduleOrientationForCycle(cycleIndex + 1); got != want { + t.Fatalf("cycle %d orientation=%q want %q", cycleIndex+1, got, want) + } } offsets := ProductionPhaseSweepOffsetsNS() wantOffsets := []int64{0, 125_000, 250_000, 375_000, 500_000, 625_000, 750_000, 875_000} @@ -359,14 +426,22 @@ func TestFinalizeRejectsWeakenedPolicyAndOutOfOrderSamples(t *testing.T) { } }) - t.Run("non-ABBA block order", func(t *testing.T) { + t.Run("non-balanced block order", func(t *testing.T) { report := validReport(t) report.Runs[2].Transport = TransportUSBIP - if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "ABBA") { + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "order") { t.Fatalf("block order error=%v", err) } }) + t.Run("orientation does not match cycle", func(t *testing.T) { + report := validReport(t) + report.Workload.ScheduleOrientation = ScheduleOrientationBAAB + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "balanced schedule") { + t.Fatalf("cycle orientation error=%v", err) + } + }) + t.Run("phase sweep drift", func(t *testing.T) { report := validReport(t) report.Workload.PhaseSweepOffsetsNS[1]++ @@ -537,7 +612,7 @@ func TestFinalizeRejectsSameMachineNativeTailRegression(t *testing.T) { func validReport(t *testing.T) *Report { t.Helper() report := &Report{ - Schema: SchemaV2, + Schema: SchemaV3, GeneratedAt: time.Date(2026, 8, 11, 12, 0, 0, 0, time.UTC), Provenance: Provenance{ SourceRevision: strings.Repeat("a", 40), @@ -545,6 +620,7 @@ func validReport(t *testing.T) *Report { SDLBinaryPath: `C:\source\SDL3.dll`, SDLBinarySHA256: strings.Repeat("c", 64), NativePackageManifestSHA256: strings.Repeat("d", 64), + NativePackageValidationMode: PackageValidationProduction, NativeDriverSHA256: strings.Repeat("e", 64), NativeDriverBuildIdentity: strings.Repeat("1", 64), QPCFrequency: 1_000_000_000, @@ -553,6 +629,7 @@ func validReport(t *testing.T) *Report { TraceProfileSHA256: strings.Repeat("f", 64), USBIPBaselineMode: USBIPBaselineMode, USBIPBaselineVersion: USBIPBaselineVersion, + USBIPRuntime: validUSBIPRuntimeProvenance(), GoVersion: "go1.26.2", GOOS: "windows", GOARCH: "amd64", @@ -583,6 +660,10 @@ func validReport(t *testing.T) *Report { InterTransitionDelayNS: int64(2 * time.Millisecond), PhaseSweepOffsetsNS: ProductionPhaseSweepOffsetsNS(), Authentication: AuthenticationMode, + ScheduleOrientation: ScheduleOrientationABBA, + CycleID: strings.Repeat("5", 32), + CycleIndex: 1, + CycleCount: 2, }, Policy: Policy{ MinimumSamplePairs: MinimumProductionSamplePairs, @@ -596,7 +677,8 @@ func validReport(t *testing.T) *Report { } report.Workload.PhaseSweepSHA256 = PhaseSweepScheduleSHA256(report.Workload.PhaseSweepOffsetsNS) - for runIndex, block := range ProductionBlockSchedule(MinimumProductionSamplePairs) { + for runIndex, block := range ProductionBlockScheduleForOrientation( + MinimumProductionSamplePairs, report.Workload.ScheduleOrientation) { run := Run{ Order: block.Order, TransportBlock: block.TransportBlock, @@ -673,12 +755,14 @@ func validReport(t *testing.T) *Report { LatencyNS: base, EventTimestampNS: timestamp, SDLFenceTimestampNS: timestamp - 1, StartQPCTicks: pressStartQPC, EndQPCTicks: pressEndQPC, MarkerQPCTicks: pressEndQPC + 1, - MarkerID: SampleMarkerID("xbox360", run.Transport, run.TransportBlock, sequence, TransitionPress)}, + MarkerID: SampleMarkerID(report.Workload.CycleID, report.Workload.CycleIndex, + "xbox360", run.Transport, run.TransportBlock, sequence, TransitionPress)}, Sample{Sequence: sequence, Transition: TransitionRelease, LatencyNS: base + 5, EventTimestampNS: timestamp + 1, SDLFenceTimestampNS: timestamp, StartQPCTicks: releaseStartQPC, EndQPCTicks: releaseEndQPC, MarkerQPCTicks: releaseEndQPC + 1, - MarkerID: SampleMarkerID("xbox360", run.Transport, run.TransportBlock, sequence, TransitionRelease)}) + MarkerID: SampleMarkerID(report.Workload.CycleID, report.Workload.CycleIndex, + "xbox360", run.Transport, run.TransportBlock, sequence, TransitionRelease)}) qpcCursor = pressStartQPC + 20_000_000 } report.Runs = append(report.Runs, run) @@ -692,6 +776,55 @@ func validReport(t *testing.T) *Report { return report } +func validUSBIPRuntimeProvenance() USBIPRuntimeProvenance { + signed := func(path, hash string) USBIPFileIdentity { + return USBIPFileIdentity{ + Path: path, Length: 1024, SHA256: hash, + SignatureStatus: "Valid", + SignerSubject: "CN=Microsoft Windows Hardware Compatibility Publisher, O=Microsoft Corporation", + SignerThumbprint: strings.Repeat("a", 40), + } + } + unsigned := func(path, hash string) USBIPFileIdentity { + return USBIPFileIdentity{Path: path, Length: 512, SHA256: hash} + } + proof := USBIPRuntimeProvenance{ + Schema: USBIPRuntimeSchemaV1, + Services: []USBIPServiceProvenance{ + { + Name: "usbip2_filter", Start: 3, Type: 1, PublishedINFName: "oem55.inf", + Image: signed(`C:\Windows\System32\DriverStore\FileRepository\usbip2_filter\usbip2_filter.sys`, strings.Repeat("6", 64)), + INF: unsigned(`C:\Windows\System32\DriverStore\FileRepository\usbip2_filter\usbip2_filter.inf`, strings.Repeat("7", 64)), + PublishedINF: unsigned(`C:\Windows\INF\oem55.inf`, strings.Repeat("7", 64)), + Catalog: signed(`C:\Windows\System32\DriverStore\FileRepository\usbip2_filter\usbip2_filter.cat`, strings.Repeat("8", 64)), + }, + { + Name: "usbip2_ude", Start: 3, Type: 1, PublishedINFName: "oem64.inf", + Image: signed(`C:\Windows\System32\DriverStore\FileRepository\usbip2_ude\usbip2_ude.sys`, strings.Repeat("9", 64)), + INF: unsigned(`C:\Windows\System32\DriverStore\FileRepository\usbip2_ude\usbip2_ude.inf`, strings.Repeat("b", 64)), + PublishedINF: unsigned(`C:\Windows\INF\oem64.inf`, strings.Repeat("b", 64)), + Catalog: signed(`C:\Windows\System32\DriverStore\FileRepository\usbip2_ude\usbip2_ude.cat`, strings.Repeat("c", 64)), + }, + }, + RootControllers: []USBIPRootControllerProvenance{ + { + InstanceID: `ROOT\USB\0001`, HardwareIDs: []string{`ROOT\USBIP_WIN2\UDE`}, + Service: "usbip2_ude", Provider: "USBIP-WIN2", DriverVersion: "21.14.27.907", + PublishedINF: "oem64.inf", Signer: "Microsoft Windows Hardware Compatibility Publisher", + IsSigned: true, + }, + }, + } + raw, err := json.Marshal(proof) + if err != nil { + panic(err) + } + digest := sha256.Sum256(raw) + proof.CaptureSHA256 = hex.EncodeToString(digest[:]) + proof.CaptureBase64 = base64.StdEncoding.EncodeToString(raw) + return proof +} + func setSampleLatency(sample *Sample, latencyNS, qpcFrequency int64) { sample.LatencyNS = latencyNS sample.EndQPCTicks = sample.StartQPCTicks + latencyNS*qpcFrequency/int64(time.Second) @@ -702,7 +835,7 @@ func validSuite(t *testing.T) *SuiteReport { t.Helper() xbox := validReport(t) suite := &SuiteReport{ - Schema: SuiteSchemaV2, GeneratedAt: xbox.GeneratedAt, Provenance: xbox.Provenance, + Schema: SuiteSchemaV3, GeneratedAt: xbox.GeneratedAt, Provenance: xbox.Provenance, } identities := []struct { controller string @@ -735,7 +868,8 @@ func validSuite(t *testing.T) *SuiteReport { run.Controller.SDLPath = identity.controller + "-" + run.Transport for sampleIndex := range run.Samples { sample := &run.Samples[sampleIndex] - sample.MarkerID = SampleMarkerID(identity.controller, run.Transport, + sample.MarkerID = SampleMarkerID(report.Workload.CycleID, + report.Workload.CycleIndex, identity.controller, run.Transport, run.TransportBlock, sample.Sequence, sample.Transition) } } diff --git a/_testing/e2e/latency/superiority.go b/_testing/e2e/latency/superiority.go new file mode 100644 index 00000000..ba7f31ac --- /dev/null +++ b/_testing/e2e/latency/superiority.go @@ -0,0 +1,384 @@ +package latency + +import ( + "errors" + "fmt" + "math" + "reflect" + "sort" + "strings" + "time" +) + +const ( + SuperioritySchemaV1 = "viiper.controller-to-game.latency-superiority/v1" + SuperiorityMinimumCycles = 6 + SuperiorityMethod = "cycle-level paired native-minus-usbip all-observed-cycle strict superiority" + SuperiorityInferenceScope = "descriptive for this exact source-bound machine session; no iid or population-confidence claim" + SuperiorityRequiredMargin = 0.0 +) + +// SuperiorityCycle binds one strictly parsed suite to the process priority +// under which its balanced transport order was captured. +type SuperiorityCycle struct { + Priority string + Suite *SuiteReport +} + +type CycleEstimate struct { + CycleIndex int `json:"cycle_index"` + Orientation string `json:"orientation"` + NativeMinusUSBIPNS float64 `json:"native_minus_usbip_ns"` +} + +type SuperiorityMetric struct { + Priority string `json:"priority_class"` + Controller string `json:"controller_type"` + Transition Transition `json:"transition"` + Metric string `json:"metric"` + Cycles []CycleEstimate `json:"cycles"` + MeanNativeMinusUSBIPNS float64 `json:"mean_native_minus_usbip_ns"` + MedianNativeMinusUSBIPNS float64 `json:"median_native_minus_usbip_ns"` + WorstCycleNativeMinusUSBIPNS float64 `json:"worst_cycle_native_minus_usbip_ns"` + Wins int `json:"wins"` + LossesOrTies int `json:"losses_or_ties"` + AllObservedCyclesFaster bool `json:"all_observed_cycles_faster"` + Verdict string `json:"verdict"` +} + +type SuperiorityReport struct { + Schema string `json:"schema"` + GeneratedAt time.Time `json:"generated_at"` + SourceRevision string `json:"source_revision"` + NativePackageManifestSHA256 string `json:"native_package_manifest_sha256"` + NativePackageValidationMode string `json:"native_package_validation_mode"` + NativeLocalTestCertificateSHA256 string `json:"native_local_test_certificate_sha256,omitempty"` + NativeDriverSHA256 string `json:"native_driver_sha256"` + NativeDriverBuildIdentity string `json:"native_driver_build_identity"` + CycleID string `json:"cycle_id"` + CycleCount int `json:"cycle_count"` + CyclesPerPriority int `json:"cycles_per_priority"` + SamplePairsPerCycle int `json:"sample_pairs_per_cycle"` + Method string `json:"method"` + InferenceScope string `json:"inference_scope"` + Metrics []SuperiorityMetric `json:"metrics"` + Verdict string `json:"verdict"` + Failures []string `json:"failures"` +} + +type superiorityCycleData struct { + priority string + suite *SuiteReport + cycleIndex int + orientation string + generatedAt time.Time + minStartQPC int64 + maxMarkerQPC int64 +} + +// AnalyzeSuperiority treats each counterbalanced cycle as one observed unit. +// It makes no independence, population-confidence, or cross-machine claim. +// Every controller, press/release direction, and mean/p95/p99 metric must be +// lower in every observed cycle at both process priorities. +func AnalyzeSuperiority(cycles []SuperiorityCycle, generatedAt time.Time) (*SuperiorityReport, error) { + if generatedAt.IsZero() { + return nil, errors.New("superiority generated_at is required") + } + if len(cycles) < 2*SuperiorityMinimumCycles || len(cycles)%2 != 0 { + return nil, fmt.Errorf("superiority analysis requires an even total with at least %d cycles per priority", + SuperiorityMinimumCycles) + } + + data := make([]superiorityCycleData, 0, len(cycles)) + seenCycle := make(map[int]bool, len(cycles)) + priorityCounts := map[string]int{"normal": 0, "high": 0} + var sourceRevision, cycleID string + var samplePairs int + var reference Provenance + for inputIndex, cycle := range cycles { + if cycle.Priority != "normal" && cycle.Priority != "high" { + return nil, fmt.Errorf("cycle %d has unsupported priority %q", inputIndex, cycle.Priority) + } + if cycle.Suite == nil { + return nil, fmt.Errorf("cycle %d has nil suite", inputIndex) + } + if err := RequireSuitePass(cycle.Suite); err != nil { + return nil, fmt.Errorf("cycle %d is not a passing source suite: %w", inputIndex, err) + } + if len(cycle.Suite.Cases) != 3 { + return nil, fmt.Errorf("cycle %d has %d controller cases", inputIndex, len(cycle.Suite.Cases)) + } + workload := cycle.Suite.Cases[0].Workload + if workload.CycleCount != len(cycles) || seenCycle[workload.CycleIndex] || + workload.CycleIndex < 1 || workload.CycleIndex > len(cycles) { + return nil, fmt.Errorf("cycle %d has duplicate or contradictory cycle identity", inputIndex) + } + seenCycle[workload.CycleIndex] = true + if workload.ScheduleOrientation != ScheduleOrientationForCycle(workload.CycleIndex) { + return nil, fmt.Errorf("cycle %d orientation is not deterministic", workload.CycleIndex) + } + if cycle.Suite.Provenance.Machine.ProcessPriorityClass != cycle.Priority { + return nil, fmt.Errorf("cycle %d priority label does not match machine provenance", + workload.CycleIndex) + } + wantPriority := "normal" + if workload.CycleIndex > len(cycles)/2 { + wantPriority = "high" + } + if cycle.Priority != wantPriority { + return nil, fmt.Errorf("cycle %d priority=%s want %s for canonical matrix order", + workload.CycleIndex, cycle.Priority, wantPriority) + } + if inputIndex == 0 { + sourceRevision = cycle.Suite.Provenance.SourceRevision + cycleID = workload.CycleID + samplePairs = workload.SamplePairs + reference = cycle.Suite.Provenance + reference.Machine.ProcessPriorityClass = "" + } else { + candidate := cycle.Suite.Provenance + candidate.Machine.ProcessPriorityClass = "" + if !reflect.DeepEqual(candidate, reference) { + return nil, fmt.Errorf("cycle %d source, package, toolchain, or machine provenance drifted", + workload.CycleIndex) + } + if workload.CycleID != cycleID || workload.SamplePairs != samplePairs { + return nil, fmt.Errorf("cycle %d workload identity drifted", workload.CycleIndex) + } + } + for caseIndex := range cycle.Suite.Cases { + caseWorkload := cycle.Suite.Cases[caseIndex].Workload + if caseWorkload.CycleID != workload.CycleID || + caseWorkload.CycleIndex != workload.CycleIndex || + caseWorkload.CycleCount != workload.CycleCount || + caseWorkload.ScheduleOrientation != workload.ScheduleOrientation { + return nil, fmt.Errorf("cycle %d controller workloads disagree", workload.CycleIndex) + } + } + minStartQPC := int64(math.MaxInt64) + maxMarkerQPC := int64(0) + for caseIndex := range cycle.Suite.Cases { + for runIndex := range cycle.Suite.Cases[caseIndex].Runs { + for _, sample := range cycle.Suite.Cases[caseIndex].Runs[runIndex].Samples { + if sample.StartQPCTicks < minStartQPC { + minStartQPC = sample.StartQPCTicks + } + if sample.MarkerQPCTicks > maxMarkerQPC { + maxMarkerQPC = sample.MarkerQPCTicks + } + } + } + } + if minStartQPC <= 0 || maxMarkerQPC <= minStartQPC { + return nil, fmt.Errorf("cycle %d has no canonical QPC measurement interval", + workload.CycleIndex) + } + priorityCounts[cycle.Priority]++ + data = append(data, superiorityCycleData{ + priority: cycle.Priority, suite: cycle.Suite, + cycleIndex: workload.CycleIndex, orientation: workload.ScheduleOrientation, + generatedAt: cycle.Suite.GeneratedAt, + minStartQPC: minStartQPC, maxMarkerQPC: maxMarkerQPC, + }) + } + cyclesPerPriority := len(cycles) / 2 + if cyclesPerPriority < SuperiorityMinimumCycles || cyclesPerPriority%2 != 0 || + priorityCounts["normal"] != cyclesPerPriority || priorityCounts["high"] != cyclesPerPriority { + return nil, errors.New("normal and high priorities require equal, even, counterbalanced cycle counts") + } + sort.Slice(data, func(i, j int) bool { return data[i].cycleIndex < data[j].cycleIndex }) + for index := range data { + if data[index].cycleIndex != index+1 { + return nil, errors.New("latency cycle indices are not contiguous") + } + if index != 0 && (!data[index].generatedAt.After(data[index-1].generatedAt) || + data[index].minStartQPC <= data[index-1].maxMarkerQPC) { + return nil, fmt.Errorf("cycle %d reuses or overlaps prior wall-clock/QPC evidence", + data[index].cycleIndex) + } + } + + result := &SuperiorityReport{ + Schema: SuperioritySchemaV1, GeneratedAt: generatedAt, + SourceRevision: sourceRevision, CycleID: cycleID, + NativePackageManifestSHA256: reference.NativePackageManifestSHA256, + NativePackageValidationMode: reference.NativePackageValidationMode, + NativeLocalTestCertificateSHA256: reference.NativeLocalTestCertificateSHA256, + NativeDriverSHA256: reference.NativeDriverSHA256, + NativeDriverBuildIdentity: reference.NativeDriverBuildIdentity, + CycleCount: len(cycles), CyclesPerPriority: cyclesPerPriority, + SamplePairsPerCycle: samplePairs, Method: SuperiorityMethod, + InferenceScope: SuperiorityInferenceScope, + } + for _, priority := range []string{"normal", "high"} { + for caseIndex := 0; caseIndex < 3; caseIndex++ { + controller := data[0].suite.Cases[caseIndex].Workload.ControllerType + for _, transition := range []Transition{TransitionPress, TransitionRelease} { + cycleMetrics := make(map[string][]CycleEstimate, 3) + for _, cycle := range data { + if cycle.priority != priority { + continue + } + report := &cycle.suite.Cases[caseIndex] + metrics, err := cycleSuperiorityMetrics(report, transition) + if err != nil { + return nil, fmt.Errorf("cycle %d %s/%s: %w", cycle.cycleIndex, + controller, transition, err) + } + for metric, value := range metrics { + cycleMetrics[metric] = append(cycleMetrics[metric], CycleEstimate{ + CycleIndex: cycle.cycleIndex, Orientation: cycle.orientation, + NativeMinusUSBIPNS: value, + }) + } + } + for _, metricName := range []string{"mean", "p95", "p99"} { + metric, err := summarizeSuperiorityMetric(priority, controller, transition, + metricName, cycleMetrics[metricName]) + if err != nil { + return nil, err + } + if metric.Verdict != "pass" { + result.Failures = append(result.Failures, fmt.Sprintf( + "%s/%s/%s %s was not lower in every observed balanced cycle", + priority, controller, transition, metricName)) + } + result.Metrics = append(result.Metrics, metric) + } + } + } + } + if len(result.Failures) == 0 { + result.Verdict = "pass" + } else { + result.Verdict = "fail" + } + return result, nil +} + +func cycleSuperiorityMetrics(report *Report, transition Transition) (map[string]float64, error) { + values := map[string][]int64{TransportUSBIP: nil, TransportNativeUDE: nil} + sequences := map[string]map[int]int64{ + TransportUSBIP: {}, TransportNativeUDE: {}, + } + for runIndex := range report.Runs { + run := &report.Runs[runIndex] + for _, sample := range run.Samples { + if sample.Transition != transition { + continue + } + if _, exists := sequences[run.Transport][sample.Sequence]; exists { + return nil, fmt.Errorf("duplicate %s sequence %d", run.Transport, sample.Sequence) + } + sequences[run.Transport][sample.Sequence] = sample.LatencyNS + } + } + pairedDifferences := make([]int64, 0, report.Workload.SamplePairs) + for sequence := 1; sequence <= report.Workload.SamplePairs; sequence++ { + usbip, usbipOK := sequences[TransportUSBIP][sequence] + native, nativeOK := sequences[TransportNativeUDE][sequence] + if !usbipOK || !nativeOK { + return nil, fmt.Errorf("missing paired sequence %d", sequence) + } + values[TransportUSBIP] = append(values[TransportUSBIP], usbip) + values[TransportNativeUDE] = append(values[TransportNativeUDE], native) + pairedDifferences = append(pairedDifferences, native-usbip) + } + usbipDistribution, err := Calculate(values[TransportUSBIP]) + if err != nil { + return nil, err + } + nativeDistribution, err := Calculate(values[TransportNativeUDE]) + if err != nil { + return nil, err + } + var exactDifferenceSum int64 + for _, value := range pairedDifferences { + if (value > 0 && exactDifferenceSum > math.MaxInt64-value) || + (value < 0 && exactDifferenceSum < math.MinInt64-value) { + return nil, errors.New("paired latency difference sum overflow") + } + exactDifferenceSum += value + } + meanDifference := float64(exactDifferenceSum) / float64(len(pairedDifferences)) + return map[string]float64{ + "mean": meanDifference, + "p95": float64(nativeDistribution.P95NS - usbipDistribution.P95NS), + "p99": float64(nativeDistribution.P99NS - usbipDistribution.P99NS), + }, nil +} + +func summarizeSuperiorityMetric(priority, controller string, transition Transition, + metricName string, estimates []CycleEstimate) (SuperiorityMetric, error) { + if len(estimates) < SuperiorityMinimumCycles { + return SuperiorityMetric{}, fmt.Errorf("%s/%s/%s %s has only %d cycles", + priority, controller, transition, metricName, len(estimates)) + } + mean := 0.0 + wins := 0 + ordered := make([]float64, 0, len(estimates)) + worst := math.Inf(-1) + for index, estimate := range estimates { + mean += (estimate.NativeMinusUSBIPNS - mean) / float64(index+1) + ordered = append(ordered, estimate.NativeMinusUSBIPNS) + if estimate.NativeMinusUSBIPNS < SuperiorityRequiredMargin { + wins++ + } + if estimate.NativeMinusUSBIPNS > worst { + worst = estimate.NativeMinusUSBIPNS + } + } + sort.Float64s(ordered) + median := ordered[len(ordered)/2] + if len(ordered)%2 == 0 { + median = (ordered[len(ordered)/2-1] + ordered[len(ordered)/2]) / 2 + } + allObservedCyclesFaster := wins == len(estimates) && worst < 0 + verdict := "fail" + if allObservedCyclesFaster { + verdict = "pass" + } + return SuperiorityMetric{ + Priority: priority, Controller: controller, Transition: transition, + Metric: metricName, Cycles: append([]CycleEstimate(nil), estimates...), + MeanNativeMinusUSBIPNS: mean, MedianNativeMinusUSBIPNS: median, + WorstCycleNativeMinusUSBIPNS: worst, Wins: wins, + LossesOrTies: len(estimates) - wins, + AllObservedCyclesFaster: allObservedCyclesFaster, + Verdict: verdict, + }, nil +} + +func RequireSuperiority(report *SuperiorityReport) error { + if report == nil { + return errors.New("nil superiority report") + } + if report.Schema != SuperioritySchemaV1 || report.Verdict != "pass" || + report.Method != SuperiorityMethod || report.InferenceScope != SuperiorityInferenceScope || + !revisionPattern.MatchString(report.SourceRevision) || + !hashPattern.MatchString(report.NativePackageManifestSHA256) || + !hashPattern.MatchString(report.NativeDriverSHA256) || + !hashPattern.MatchString(report.NativeDriverBuildIdentity) || + (report.NativePackageValidationMode != PackageValidationProduction && + report.NativePackageValidationMode != PackageValidationLocalTest) || + (report.NativePackageValidationMode == PackageValidationProduction && + report.NativeLocalTestCertificateSHA256 != "") || + (report.NativePackageValidationMode == PackageValidationLocalTest && + !hashPattern.MatchString(report.NativeLocalTestCertificateSHA256)) || + len(report.Failures) != 0 { + if len(report.Failures) != 0 { + return errors.New(strings.Join(report.Failures, "; ")) + } + return fmt.Errorf("superiority report schema/verdict is %q/%q", + report.Schema, report.Verdict) + } + for _, metric := range report.Metrics { + if metric.Verdict != "pass" || !metric.AllObservedCyclesFaster || + metric.Wins != len(metric.Cycles) || metric.LossesOrTies != 0 || + metric.WorstCycleNativeMinusUSBIPNS >= SuperiorityRequiredMargin { + return fmt.Errorf("metric %s/%s/%s/%s is not strictly faster in every observed cycle", + metric.Priority, metric.Controller, metric.Transition, metric.Metric) + } + } + return nil +} diff --git a/_testing/e2e/latency/superiority_test.go b/_testing/e2e/latency/superiority_test.go new file mode 100644 index 00000000..2663b716 --- /dev/null +++ b/_testing/e2e/latency/superiority_test.go @@ -0,0 +1,222 @@ +package latency + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestAnalyzeSuperiorityRequiresEveryBalancedCycleStratumToBeFaster(t *testing.T) { + cycles := superiorityFixture(t, true) + report, err := AnalyzeSuperiority(cycles, + time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if err = RequireSuperiority(report); err != nil { + t.Fatal(err) + } + want := cycles[0].Suite.Provenance + if report.NativePackageManifestSHA256 != want.NativePackageManifestSHA256 || + report.NativePackageValidationMode != want.NativePackageValidationMode || + report.NativeLocalTestCertificateSHA256 != want.NativeLocalTestCertificateSHA256 || + report.NativeDriverSHA256 != want.NativeDriverSHA256 || + report.NativeDriverBuildIdentity != want.NativeDriverBuildIdentity { + t.Fatalf("superiority package identity is not bound: %+v", report) + } + if got, want := len(report.Metrics), 2*3*2*3; got != want { + t.Fatalf("metrics=%d want %d", got, want) + } + for _, metric := range report.Metrics { + if metric.Verdict != "pass" || metric.WorstCycleNativeMinusUSBIPNS >= 0 || + !metric.AllObservedCyclesFaster || len(metric.Cycles) != 8 { + t.Fatalf("non-superior metric: %+v", metric) + } + } + if report.InferenceScope != SuperiorityInferenceScope || + strings.Contains(strings.ToLower(report.Method+report.InferenceScope), "95%") || + strings.Contains(strings.ToLower(report.Method+report.InferenceScope), "confidence bound") { + t.Fatalf("unexpected inferential claim: method=%q scope=%q", report.Method, report.InferenceScope) + } +} + +func TestRequireSuperiorityRejectsPackageIdentityMutation(t *testing.T) { + report, err := AnalyzeSuperiority(superiorityFixture(t, true), + time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + report.NativePackageValidationMode = PackageValidationLocalTest + report.NativeLocalTestCertificateSHA256 = "" + if err = RequireSuperiority(report); err == nil { + t.Fatal("mutated package validation identity passed") + } +} + +func TestCycleSuperiorityMeanUsesExactIntegerSumForVerdict(t *testing.T) { + const samplePairs = 256 + report := &Report{Workload: Workload{SamplePairs: samplePairs}} + usbip := Run{Transport: TransportUSBIP} + native := Run{Transport: TransportNativeUDE} + for sequence := 1; sequence <= samplePairs; sequence++ { + difference := int64(-100) + if sequence-1 == 212 || sequence-1 == 248 { + difference = 12_700 + } + usbip.Samples = append(usbip.Samples, Sample{ + Sequence: sequence, Transition: TransitionPress, LatencyNS: 500_000_000, + }) + native.Samples = append(native.Samples, Sample{ + Sequence: sequence, Transition: TransitionPress, LatencyNS: 500_000_000 + difference, + }) + } + report.Runs = []Run{usbip, native} + metrics, err := cycleSuperiorityMetrics(report, TransitionPress) + if err != nil { + t.Fatal(err) + } + if metrics["mean"] != 0 { + t.Fatalf("exact mean tie drifted to %v", metrics["mean"]) + } +} + +func TestAnalyzeSuperiorityRejectsPermissivePerRunPassWhenNativeIsSlower(t *testing.T) { + cycles := superiorityFixture(t, false) + report, err := AnalyzeSuperiority(cycles, + time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if report.Verdict != "fail" || len(report.Failures) == 0 { + t.Fatalf("slower native fixture passed: %+v", report) + } + if err = RequireSuperiority(report); err == nil || + !strings.Contains(err.Error(), "every observed balanced cycle") { + t.Fatalf("slower native RequireSuperiority error=%v", err) + } +} + +func TestAnalyzeSuperiorityRejectsCycleRelabelAndProvenanceDrift(t *testing.T) { + t.Run("orientation", func(t *testing.T) { + cycles := superiorityFixture(t, true) + for caseIndex := range cycles[0].Suite.Cases { + cycles[0].Suite.Cases[caseIndex].Workload.ScheduleOrientation = ScheduleOrientationBAAB + } + if _, err := AnalyzeSuperiority(cycles, time.Now().UTC()); err == nil || + !strings.Contains(err.Error(), "orientation") { + t.Fatalf("orientation relabel error=%v", err) + } + }) + + t.Run("toolchain provenance", func(t *testing.T) { + cycles := superiorityFixture(t, true) + cycles[7].Suite.Provenance.GoExecutableSHA256 = strings.Repeat("9", 64) + for caseIndex := range cycles[7].Suite.Cases { + cycles[7].Suite.Cases[caseIndex].Provenance.GoExecutableSHA256 = strings.Repeat("9", 64) + } + if _, err := AnalyzeSuperiority(cycles, time.Now().UTC()); err == nil || + !strings.Contains(err.Error(), "provenance drifted") { + t.Fatalf("provenance drift error=%v", err) + } + }) + + t.Run("overlapping qpc evidence", func(t *testing.T) { + cycles := superiorityFixture(t, true) + for caseIndex := range cycles[1].Suite.Cases { + for runIndex := range cycles[1].Suite.Cases[caseIndex].Runs { + for sampleIndex := range cycles[1].Suite.Cases[caseIndex].Runs[runIndex].Samples { + left := &cycles[0].Suite.Cases[caseIndex].Runs[runIndex].Samples[sampleIndex] + right := &cycles[1].Suite.Cases[caseIndex].Runs[runIndex].Samples[sampleIndex] + right.StartQPCTicks = left.StartQPCTicks + right.EndQPCTicks = left.EndQPCTicks + right.MarkerQPCTicks = left.MarkerQPCTicks + } + } + } + if _, err := AnalyzeSuperiority(cycles, time.Now().UTC()); err == nil || + !strings.Contains(err.Error(), "overlaps prior") { + t.Fatalf("overlapping QPC evidence error=%v", err) + } + }) +} + +func superiorityFixture(t *testing.T, nativeFaster bool) []SuperiorityCycle { + t.Helper() + base := validSuite(t) + cycles := make([]SuperiorityCycle, 0, 16) + for cycleIndex := 1; cycleIndex <= 16; cycleIndex++ { + suite := cloneSuite(t, base) + priority := "normal" + if cycleIndex > 8 { + priority = "high" + } + generatedAt := base.GeneratedAt.Add(time.Duration(cycleIndex) * time.Minute) + suite.GeneratedAt = generatedAt + suite.Provenance.Machine.ProcessPriorityClass = priority + orientation := ScheduleOrientationForCycle(cycleIndex) + for caseIndex := range suite.Cases { + report := &suite.Cases[caseIndex] + report.GeneratedAt = generatedAt + report.Provenance.Machine.ProcessPriorityClass = priority + report.Workload.ScheduleOrientation = orientation + report.Workload.CycleID = strings.Repeat("7", 32) + report.Workload.CycleIndex = cycleIndex + report.Workload.CycleCount = 16 + if orientation == ScheduleOrientationBAAB { + report.Runs = []Run{report.Runs[1], report.Runs[0], report.Runs[3], report.Runs[2]} + for runIndex := range report.Runs { + report.Runs[runIndex].Order = runIndex + 1 + } + } + if nativeFaster { + for runIndex := range report.Runs { + run := &report.Runs[runIndex] + if run.Transport != TransportNativeUDE { + continue + } + for sampleIndex := range run.Samples { + setSampleLatency(&run.Samples[sampleIndex], + run.Samples[sampleIndex].LatencyNS-200_000, + report.Provenance.QPCFrequency) + } + } + } + for runIndex := range report.Runs { + run := &report.Runs[runIndex] + for sampleIndex := range run.Samples { + sample := &run.Samples[sampleIndex] + sample.MarkerID = SampleMarkerID(report.Workload.CycleID, + report.Workload.CycleIndex, report.Workload.ControllerType, + run.Transport, run.TransportBlock, sample.Sequence, sample.Transition) + sample.EventTimestampNS = uint64(runIndex+1)*1_000_000_000 + + uint64(sampleIndex+1) + sample.SDLFenceTimestampNS = sample.EventTimestampNS - 1 + sample.StartQPCTicks = int64(cycleIndex)*10_000_000_000_000 + + int64(runIndex+1)*1_000_000_000_000 + + int64(sampleIndex+1)*20_000_000 + setSampleLatency(sample, sample.LatencyNS, + report.Provenance.QPCFrequency) + } + } + } + if err := FinalizeSuite(suite); err != nil { + t.Fatalf("cycle %d: %v", cycleIndex, err) + } + cycles = append(cycles, SuperiorityCycle{Priority: priority, Suite: suite}) + } + return cycles +} + +func cloneSuite(t *testing.T, suite *SuiteReport) *SuiteReport { + t.Helper() + data, err := json.Marshal(suite) + if err != nil { + t.Fatal(err) + } + var clone SuiteReport + if err = json.Unmarshal(data, &clone); err != nil { + t.Fatal(err) + } + return &clone +} diff --git a/_testing/e2e/latency/trace_markers.go b/_testing/e2e/latency/trace_markers.go index 0faad644..17f5da60 100644 --- a/_testing/e2e/latency/trace_markers.go +++ b/_testing/e2e/latency/trace_markers.go @@ -1,12 +1,20 @@ package latency import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" "io" + "os" + "regexp" ) +const TraceMarkerEvidenceSchemaV1 = "viiper.controller-to-game.latency-trace-markers/v1" + +var traceEvidenceHashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + type TraceMarker struct { MarkerID string `json:"trace_marker_id"` Controller string `json:"controller"` @@ -22,11 +30,18 @@ type TraceMarker struct { SDLFenceTimestampNS uint64 `json:"sdl_prewrite_fence_timestamp_ns"` } -func ParseTraceMarkers(reader io.Reader) ([]TraceMarker, error) { +type TraceMarkerEvidence struct { + Schema string `json:"schema"` + SourceTraceLength int64 `json:"source_trace_length"` + SourceTraceSHA256 string `json:"source_trace_sha256"` + Markers []TraceMarker `json:"markers"` +} + +func ParseTraceMarkerEvidence(reader io.Reader) (*TraceMarkerEvidence, error) { decoder := json.NewDecoder(reader) decoder.DisallowUnknownFields() - var markers []TraceMarker - if err := decoder.Decode(&markers); err != nil { + var evidence TraceMarkerEvidence + if err := decoder.Decode(&evidence); err != nil { return nil, fmt.Errorf("decode ETL marker evidence: %w", err) } var trailing any @@ -36,7 +51,45 @@ func ParseTraceMarkers(reader io.Reader) ([]TraceMarker, error) { } return nil, fmt.Errorf("decode trailing ETL marker evidence: %w", err) } - return markers, nil + if evidence.Schema != TraceMarkerEvidenceSchemaV1 || evidence.SourceTraceLength <= 0 || + !traceEvidenceHashPattern.MatchString(evidence.SourceTraceSHA256) || + len(evidence.Markers) == 0 { + return nil, errors.New("ETL marker evidence header is incomplete or noncanonical") + } + return &evidence, nil +} + +// VerifyTraceMarkerSource binds the decoded marker envelope to the exact raw +// sequential ETL file from which PowerShell decoded it. +func VerifyTraceMarkerSource(evidence *TraceMarkerEvidence, tracePath string) error { + if evidence == nil { + return errors.New("nil ETL marker evidence") + } + info, err := os.Lstat(tracePath) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || + info.Size() != evidence.SourceTraceLength { + return errors.New("raw ETL is not the exact regular file bound by marker evidence") + } + file, err := os.Open(tracePath) + if err != nil { + return err + } + hash := sha256.New() + _, copyErr := io.Copy(hash, file) + closeErr := file.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + if hex.EncodeToString(hash.Sum(nil)) != evidence.SourceTraceSHA256 { + return errors.New("raw ETL SHA-256 does not match decoded marker evidence") + } + return nil } // VerifyTraceMarkers requires an exact, chronological, one-to-one copy of every diff --git a/_testing/e2e/latency/trace_markers_test.go b/_testing/e2e/latency/trace_markers_test.go index 14f6dcaa..c295388d 100644 --- a/_testing/e2e/latency/trace_markers_test.go +++ b/_testing/e2e/latency/trace_markers_test.go @@ -2,7 +2,11 @@ package latency import ( "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" + "os" + "path/filepath" "strings" "testing" ) @@ -34,17 +38,36 @@ func TestTraceMarkerEvidenceRejectsMissingDuplicateTruncatedAndForged(t *testing }) } - encoded, err := json.Marshal(markers) + tracePath := filepath.Join(t.TempDir(), "cycle.etl") + traceBytes := []byte("exact sequential ETL fixture") + if err := os.WriteFile(tracePath, traceBytes, 0o600); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(traceBytes) + evidence := TraceMarkerEvidence{ + Schema: TraceMarkerEvidenceSchemaV1, SourceTraceLength: int64(len(traceBytes)), + SourceTraceSHA256: hex.EncodeToString(digest[:]), Markers: markers, + } + if err := VerifyTraceMarkerSource(&evidence, tracePath); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(evidence) if err != nil { t.Fatal(err) } - if _, err = ParseTraceMarkers(bytes.NewReader(encoded[:len(encoded)-1])); err == nil { + if _, err = ParseTraceMarkerEvidence(bytes.NewReader(encoded[:len(encoded)-1])); err == nil { t.Fatal("truncated marker JSON was accepted") } - if _, err = ParseTraceMarkers(bytes.NewReader(append(encoded, []byte(` {}`)...))); err == nil || + if _, err = ParseTraceMarkerEvidence(bytes.NewReader(append(encoded, []byte(` {}`)...))); err == nil || !strings.Contains(err.Error(), "trailing JSON") { t.Fatalf("trailing marker JSON error=%v", err) } + if err = os.WriteFile(tracePath, []byte("swapped ETL"), 0o600); err != nil { + t.Fatal(err) + } + if err = VerifyTraceMarkerSource(&evidence, tracePath); err == nil { + t.Fatal("swapped raw ETL was accepted") + } } func traceMarkersFromSuite(suite *SuiteReport) []TraceMarker { diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 85ef0550..2684f220 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -3,9 +3,11 @@ package e2e_bench_test import ( + "bytes" "context" "crypto/sha256" "encoding" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -44,11 +46,19 @@ const ( liveLatencyPreflight = "VIIPER_E2E_PRODUCTION_PREFLIGHT" liveLatencyOutput = "VIIPER_E2E_LATENCY_OUTPUT" liveLatencySamples = "VIIPER_E2E_LATENCY_SAMPLES" + liveLatencyUSBIPRuntime = "VIIPER_E2E_USBIP_RUNTIME_PROVENANCE" + liveLatencyUSBIPRuntimeSHA = "VIIPER_E2E_USBIP_RUNTIME_PROVENANCE_SHA256" + liveLatencyOrientation = "VIIPER_E2E_LATENCY_ORIENTATION" + liveLatencyCycleID = "VIIPER_E2E_LATENCY_CYCLE_ID" + liveLatencyCycleIndex = "VIIPER_E2E_LATENCY_CYCLE_INDEX" + liveLatencyCycleCount = "VIIPER_E2E_LATENCY_CYCLE_COUNT" liveLatencyExpectedRevision = "VIIPER_E2E_EXPECTED_SOURCE_REVISION" liveLatencySDLRevision = "VIIPER_E2E_SDL_SOURCE_REVISION" liveLatencySDLDLL = "VIIPER_E2E_SDL_DLL_PATH" liveLatencySDLSHA256 = "VIIPER_E2E_SDL_DLL_SHA256" liveLatencyPackageManifest = "VIIPER_E2E_PACKAGE_MANIFEST_SHA256" + liveLatencyPackageMode = "VIIPER_E2E_PACKAGE_VALIDATION_MODE" + liveLatencyLocalTestCertSHA = "VIIPER_E2E_LOCAL_TEST_CERTIFICATE_SHA256" liveLatencyDriverSHA256 = "VIIPER_E2E_NATIVE_DRIVER_SHA256" liveLatencyTraceProfileSHA = "VIIPER_E2E_TRACE_PROFILE_SHA256" liveLatencyDriverBuildID = "VIIPER_E2E_NATIVE_DRIVER_BUILD_IDENTITY" @@ -72,11 +82,18 @@ const ( type liveLatencyConfig struct { outputPath string samplePairs int + scheduleOrientation string + cycleID string + cycleIndex int + cycleCount int + usbipRuntime latency.USBIPRuntimeProvenance expectedRevision string sdlRevision string sdlDLLPath string sdlDLLSHA256 string packageManifestSHA string + packageMode string + localTestCertSHA string driverSHA256 string traceProfileSHA256 string driverBuildIdentity string @@ -215,32 +232,35 @@ func TestLiveControllerToGameLatencyGate(t *testing.T) { generatedAt := time.Now().UTC() provenance := latency.Provenance{ - SourceRevision: config.expectedRevision, - SDLSourceRevision: config.sdlRevision, - SDLBinaryPath: loadedSDL, - SDLBinarySHA256: loadedHash, - NativePackageManifestSHA256: config.packageManifestSHA, - NativeDriverSHA256: config.driverSHA256, - NativeDriverBuildIdentity: config.driverBuildIdentity, - QPCFrequency: qpcFrequency, - TraceProviderName: latency.TraceProviderName, - TraceProviderGUID: latency.TraceProviderGUID, - TraceProfileSHA256: config.traceProfileSHA256, - USBIPBaselineMode: latency.USBIPBaselineMode, - USBIPBaselineVersion: latency.USBIPBaselineVersion, - GoVersion: runtime.Version(), - GOOS: runtime.GOOS, - GOARCH: runtime.GOARCH, - GitExecutablePath: config.gitPath, - GitExecutableSHA256: config.gitSHA256, - GoExecutablePath: config.goPath, - GoExecutableSHA256: config.goSHA256, - WPRExecutablePath: config.wprPath, - WPRExecutableSHA256: config.wprSHA256, - Machine: machine, + SourceRevision: config.expectedRevision, + SDLSourceRevision: config.sdlRevision, + SDLBinaryPath: loadedSDL, + SDLBinarySHA256: loadedHash, + NativePackageManifestSHA256: config.packageManifestSHA, + NativePackageValidationMode: config.packageMode, + NativeLocalTestCertificateSHA256: config.localTestCertSHA, + NativeDriverSHA256: config.driverSHA256, + NativeDriverBuildIdentity: config.driverBuildIdentity, + QPCFrequency: qpcFrequency, + TraceProviderName: latency.TraceProviderName, + TraceProviderGUID: latency.TraceProviderGUID, + TraceProfileSHA256: config.traceProfileSHA256, + USBIPBaselineMode: latency.USBIPBaselineMode, + USBIPBaselineVersion: latency.USBIPBaselineVersion, + USBIPRuntime: config.usbipRuntime, + GoVersion: runtime.Version(), + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + GitExecutablePath: config.gitPath, + GitExecutableSHA256: config.gitSHA256, + GoExecutablePath: config.goPath, + GoExecutableSHA256: config.goSHA256, + WPRExecutablePath: config.wprPath, + WPRExecutableSHA256: config.wprSHA256, + Machine: machine, } suite := &latency.SuiteReport{ - Schema: latency.SuiteSchemaV2, GeneratedAt: generatedAt, Provenance: provenance, + Schema: latency.SuiteSchemaV3, GeneratedAt: generatedAt, Provenance: provenance, } gateCtx, cancelGate := context.WithTimeout(context.Background(), 18*time.Minute) @@ -248,7 +268,7 @@ func TestLiveControllerToGameLatencyGate(t *testing.T) { for _, controller := range liveControllerWorkloads() { phaseSweepOffsets := latency.ProductionPhaseSweepOffsetsNS() report := latency.Report{ - Schema: latency.SchemaV2, GeneratedAt: generatedAt, Provenance: provenance, + Schema: latency.SchemaV3, GeneratedAt: generatedAt, Provenance: provenance, Workload: latency.Workload{ APIAddress: liveLatencyAPIAddress, USBIPAddress: liveLatencyUSBIPAddress, ControllerType: controller.apiType, @@ -262,6 +282,10 @@ func TestLiveControllerToGameLatencyGate(t *testing.T) { PhaseSweepOffsetsNS: phaseSweepOffsets, PhaseSweepSHA256: latency.PhaseSweepScheduleSHA256(phaseSweepOffsets), Authentication: latency.AuthenticationMode, + ScheduleOrientation: config.scheduleOrientation, + CycleID: config.cycleID, + CycleIndex: config.cycleIndex, + CycleCount: config.cycleCount, }, Policy: latency.Policy{ MinimumSamplePairs: latency.MinimumProductionSamplePairs, @@ -273,10 +297,12 @@ func TestLiveControllerToGameLatencyGate(t *testing.T) { NativeMaxOverUSBIPNS: latency.DefaultNativeMaxOverUSBIPNS, }, } - for _, block := range latency.ProductionBlockSchedule(config.samplePairs) { + for _, block := range latency.ProductionBlockScheduleForOrientation( + config.samplePairs, config.scheduleOrientation) { report.Runs = append(report.Runs, runLiveLatencyTransport(gateCtx, block, controller, traceProvider, - qpcFrequency, config.driverBuildIdentity)) + qpcFrequency, config.driverBuildIdentity, + config.cycleID, config.cycleIndex)) } if err = latency.Finalize(&report); err != nil { t.Fatalf("finalize %s source-bound latency report: %v", controller.apiType, err) @@ -326,11 +352,15 @@ func loadLiveLatencyConfig() (liveLatencyConfig, error) { } config := liveLatencyConfig{ outputPath: strings.TrimSpace(os.Getenv(liveLatencyOutput)), + scheduleOrientation: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyOrientation))), + cycleID: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyCycleID))), expectedRevision: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyExpectedRevision))), sdlRevision: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencySDLRevision))), sdlDLLPath: strings.TrimSpace(os.Getenv(liveLatencySDLDLL)), sdlDLLSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencySDLSHA256))), packageManifestSHA: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyPackageManifest))), + packageMode: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyPackageMode))), + localTestCertSHA: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyLocalTestCertSHA))), driverSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyDriverSHA256))), traceProfileSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyTraceProfileSHA))), driverBuildIdentity: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyDriverBuildID))), @@ -342,6 +372,36 @@ func loadLiveLatencyConfig() (liveLatencyConfig, error) { wprPath: strings.TrimSpace(os.Getenv(liveLatencyWPRPath)), wprSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyWPRSHA256))), } + encodedUSBIPRuntime := strings.TrimSpace(os.Getenv(liveLatencyUSBIPRuntime)) + expectedUSBIPRuntimeSHA := strings.ToLower(strings.TrimSpace( + os.Getenv(liveLatencyUSBIPRuntimeSHA))) + if len(encodedUSBIPRuntime) == 0 || len(encodedUSBIPRuntime) > 128*1024 || + len(expectedUSBIPRuntimeSHA) != 64 { + return liveLatencyConfig{}, errors.New("exact USB/IP runtime provenance environment is incomplete") + } + rawUSBIPRuntime, err := base64.StdEncoding.Strict().DecodeString(encodedUSBIPRuntime) + if err != nil || len(rawUSBIPRuntime) == 0 || len(rawUSBIPRuntime) > 96*1024 { + return liveLatencyConfig{}, errors.New("exact USB/IP runtime provenance is not canonical bounded base64") + } + runtimeDigest := sha256.Sum256(rawUSBIPRuntime) + if actual := hex.EncodeToString(runtimeDigest[:]); actual != expectedUSBIPRuntimeSHA { + return liveLatencyConfig{}, fmt.Errorf("USB/IP runtime provenance SHA-256 %s does not match %s", + actual, expectedUSBIPRuntimeSHA) + } + decoder := json.NewDecoder(bytes.NewReader(rawUSBIPRuntime)) + decoder.DisallowUnknownFields() + if err = decoder.Decode(&config.usbipRuntime); err != nil { + return liveLatencyConfig{}, fmt.Errorf("decode exact USB/IP runtime provenance: %w", err) + } + var trailing any + if err = decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return liveLatencyConfig{}, errors.New("exact USB/IP runtime provenance contains trailing JSON") + } + config.usbipRuntime.CaptureSHA256 = expectedUSBIPRuntimeSHA + config.usbipRuntime.CaptureBase64 = encodedUSBIPRuntime + if err = latency.ValidateUSBIPRuntimeProvenance(config.usbipRuntime); err != nil { + return liveLatencyConfig{}, err + } if config.outputPath == "" || config.expectedRevision == "" || config.sdlRevision == "" || config.sdlDLLPath == "" || config.sdlDLLSHA256 == "" || config.packageManifestSHA == "" || config.driverSHA256 == "" || @@ -352,6 +412,34 @@ func loadLiveLatencyConfig() (liveLatencyConfig, error) { config.wprPath == "" || config.wprSHA256 == "" { return liveLatencyConfig{}, errors.New("production latency provenance environment is incomplete") } + if (config.packageMode != latency.PackageValidationProduction || config.localTestCertSHA != "") && + (config.packageMode != latency.PackageValidationLocalTest || len(config.localTestCertSHA) != 64) { + return liveLatencyConfig{}, errors.New("package validation mode or local-test certificate SHA-256 is invalid") + } + if config.localTestCertSHA != "" { + if _, err := hex.DecodeString(config.localTestCertSHA); err != nil { + return liveLatencyConfig{}, errors.New("local-test certificate SHA-256 is not lowercase hexadecimal") + } + } + cycleIndex, cycleIndexErr := strconv.Atoi(strings.TrimSpace(os.Getenv(liveLatencyCycleIndex))) + cycleCount, cycleCountErr := strconv.Atoi(strings.TrimSpace(os.Getenv(liveLatencyCycleCount))) + if cycleIndexErr != nil || cycleCountErr != nil || cycleCount < 2 || + cycleCount%2 != 0 || cycleIndex < 1 || cycleIndex > cycleCount || + len(config.cycleID) != 32 { + return liveLatencyConfig{}, errors.New( + "balanced latency cycle identity, index, and even cycle count are invalid") + } + if _, err := hex.DecodeString(config.cycleID); err != nil { + return liveLatencyConfig{}, errors.New("latency cycle ID must be 32 lowercase hexadecimal characters") + } + wantOrientation := latency.ScheduleOrientationForCycle(cycleIndex) + if config.scheduleOrientation != wantOrientation { + return liveLatencyConfig{}, fmt.Errorf( + "latency cycle %d requires %s orientation, got %s", + cycleIndex, wantOrientation, config.scheduleOrientation) + } + config.cycleIndex = cycleIndex + config.cycleCount = cycleCount if !filepath.IsAbs(config.outputPath) || !filepath.IsAbs(config.sdlDLLPath) { return liveLatencyConfig{}, errors.New("latency output and SDL DLL paths must be absolute") } @@ -569,6 +657,8 @@ func runLiveLatencyTransport( traceProvider *latencytrace.Provider, qpcFrequency int64, expectedDriverBuildIdentity string, + cycleID string, + cycleIndex int, ) (result latency.Run) { transport := block.Transport result.Order = block.Order @@ -598,7 +688,6 @@ func runLiveLatencyTransport( } var ( busCreated bool - deviceID string deviceRegistration *viipertypes.Device gamepadID sdl.GamepadID gamepad *sdl.Gamepad @@ -704,7 +793,6 @@ func runLiveLatencyTransport( result.Failure = fmt.Sprintf("native DeviceAdd returned contradictory USB/IP port %d", device.USBIPPort) return result } - deviceID = device.DevID deviceRegistration = device result.Device = latency.DeviceProof{ BusID: 1, DeviceID: device.DevID, Type: device.Type, @@ -762,7 +850,8 @@ func runLiveLatencyTransport( lastEventTimestamp, err = measureTransition( gamepad, stream, sequence, latency.TransitionPress, true, controller.state(true), &observedDown, lastEventTimestamp, &result, - controller.apiType, transport, block.TransportBlock, traceProvider, qpcFrequency) + controller.apiType, transport, block.TransportBlock, traceProvider, qpcFrequency, + cycleID, cycleIndex) if err != nil { result.Failure = err.Error() return result @@ -776,7 +865,8 @@ func runLiveLatencyTransport( lastEventTimestamp, err = measureTransition( gamepad, stream, sequence, latency.TransitionRelease, false, controller.state(false), &observedDown, lastEventTimestamp, &result, - controller.apiType, transport, block.TransportBlock, traceProvider, qpcFrequency) + controller.apiType, transport, block.TransportBlock, traceProvider, qpcFrequency, + cycleID, cycleIndex) if err != nil { result.Failure = err.Error() return result @@ -1103,7 +1193,7 @@ func warmControllerPath( lastTimestamp, err = measureTransition( gamepad, stream, sequence, latency.TransitionPress, true, controller.state(true), &observedDown, lastTimestamp, &warmup, - "", "", 0, nil, qpcFrequency) + "", "", 0, nil, qpcFrequency, "", 0) if err != nil { return lastTimestamp, err } @@ -1115,7 +1205,7 @@ func warmControllerPath( lastTimestamp, err = measureTransition( gamepad, stream, sequence, latency.TransitionRelease, false, controller.state(false), &observedDown, lastTimestamp, &warmup, - "", "", 0, nil, qpcFrequency) + "", "", 0, nil, qpcFrequency, "", 0) if err != nil { return lastTimestamp, err } @@ -1188,6 +1278,8 @@ func measureTransition( transportBlock int, traceProvider *latencytrace.Provider, qpcFrequency int64, + cycleID string, + cycleIndex int, ) (uint64, error) { if *observedDown == wantDown { return lastTimestamp, fmt.Errorf("%s sample %d started from the wrong observed state", transition, sequence) @@ -1280,7 +1372,8 @@ func measureTransition( StartQPCTicks: startQPC, EndQPCTicks: endQPC, } if traceProvider != nil { - sample.MarkerID = latency.SampleMarkerID(controllerType, transport, transportBlock, sequence, transition) + sample.MarkerID = latency.SampleMarkerID(cycleID, cycleIndex, controllerType, + transport, transportBlock, sequence, transition) sample.MarkerQPCTicks, err = latencytrace.Counter() if err != nil { return lastTimestamp, fmt.Errorf("%s sample %d query pre-marker QPC: %w", transition, sequence, err) diff --git a/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 index d27d96ec..f9bc5ddb 100644 --- a/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 +++ b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 @@ -6,6 +6,11 @@ param( [Parameter(Mandatory = $true)] [string]$SubmissionManifestPath, + [ValidateSet('Production', 'LocalTest')] + [string]$PackageValidationMode = 'Production', + + [string]$LocalTestCertificatePath, + [Parameter(Mandatory = $true)] [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] [string]$ExpectedSourceRevision, @@ -23,6 +28,22 @@ param( [ValidateRange(256, 10000)] [int]$Samples = 256, + [Parameter(Mandatory = $true)] + [ValidateSet('ABBA', 'BAAB')] + [string]$Orientation, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{32}$')] + [string]$CycleId, + + [Parameter(Mandatory = $true)] + [ValidateRange(1, 100)] + [int]$CycleIndex, + + [Parameter(Mandatory = $true)] + [ValidateRange(2, 100)] + [int]$CycleCount, + [ValidateSet('Normal', 'High')] [string]$PriorityClass = 'Normal', @@ -108,6 +129,121 @@ function Resolve-DriverImagePath { return Resolve-CanonicalPath -Path $path } +function Get-ExactUSBIPFileIdentity { + param( + [Parameter(Mandatory = $true)][string]$Path, + [switch]$RequireValidSignature + ) + + $item = Get-Item -LiteralPath (Resolve-CanonicalPath -Path $Path) -Force -ErrorAction Stop + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -le 0) { + throw "USB/IP evidence is not a non-empty regular file: '$Path'." + } + $status = $null + $subject = $null + $thumbprint = $null + if ($RequireValidSignature) { + $signature = Get-AuthenticodeSignature -LiteralPath $item.FullName -ErrorAction Stop + if ([string]$signature.Status -cne 'Valid' -or $null -eq $signature.SignerCertificate -or + [string]$signature.SignerCertificate.Subject -notmatch '(?i)Microsoft') { + throw "USB/IP evidence is not validly Microsoft-signed: '$($item.FullName)'." + } + $status = [string]$signature.Status + $subject = [string]$signature.SignerCertificate.Subject + $thumbprint = ([string]$signature.SignerCertificate.Thumbprint).ToLowerInvariant() + if ($thumbprint -notmatch '^[0-9a-f]{40}$') { + throw "USB/IP evidence has a noncanonical signer thumbprint: '$($item.FullName)'." + } + } + return [ordered]@{ + path = $item.FullName + length = [long]$item.Length + sha256 = (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + file_version = [string]$item.VersionInfo.FileVersion + product_version = [string]$item.VersionInfo.ProductVersion + signature_status = $status + signer_subject = $subject + signer_thumbprint = $thumbprint + } +} + +function Get-ExactUSBIPRuntimeProvenance { + $services = [Collections.Generic.List[object]]::new() + foreach ($serviceName in @('usbip2_filter', 'usbip2_ude')) { + $service = Get-ItemProperty -LiteralPath "HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName" ` + -ErrorAction Stop + if ([string]::IsNullOrWhiteSpace([string]$service.ImagePath) -or + [string]$service.DisplayName -notmatch '^@(?oem[0-9]+\.inf),') { + throw "USB/IP service '$serviceName' has no exact image/published-INF identity." + } + $publishedINFName = $Matches['inf'].ToLowerInvariant() + $imagePath = Resolve-DriverImagePath -ImagePath ([string]$service.ImagePath) + $packageDirectory = Split-Path -Parent $imagePath + $infPath = Join-Path $packageDirectory "$serviceName.inf" + $catalogPath = Join-Path $packageDirectory "$serviceName.cat" + $publishedINFPath = Join-Path (Join-Path $env:SystemRoot 'INF') $publishedINFName + $image = Get-ExactUSBIPFileIdentity -Path $imagePath -RequireValidSignature + $inf = Get-ExactUSBIPFileIdentity -Path $infPath + $publishedINF = Get-ExactUSBIPFileIdentity -Path $publishedINFPath + $catalog = Get-ExactUSBIPFileIdentity -Path $catalogPath -RequireValidSignature + if ([string]$inf.sha256 -cne [string]$publishedINF.sha256 -or + [string]$image.signer_thumbprint -cne [string]$catalog.signer_thumbprint) { + throw "USB/IP service '$serviceName' package bytes or signer identity disagree." + } + $services.Add([ordered]@{ + name = $serviceName + start = [uint32]$service.Start + type = [uint32]$service.Type + published_inf_name = $publishedINFName + image = $image + inf = $inf + published_inf = $publishedINF + catalog = $catalog + }) + } + + $udePublishedINF = [string]($services[1].published_inf_name) + $rootEntities = @(Get-CimInstance -ClassName Win32_PnPEntity -ErrorAction Stop | + Where-Object { @($_.HardwareID) -contains 'ROOT\USBIP_WIN2\UDE' } | + Sort-Object PNPDeviceID) + if ($rootEntities.Count -lt 1 -or $rootEntities.Count -gt 16) { + throw "Expected 1-16 exact USB/IP root controllers; found $($rootEntities.Count)." + } + $signedDrivers = @(Get-CimInstance -ClassName Win32_PnPSignedDriver -ErrorAction Stop) + $roots = [Collections.Generic.List[object]]::new() + foreach ($root in $rootEntities) { + $rootInstanceID = [string]($root.PNPDeviceID) + $matches = @($signedDrivers | Where-Object { + ([string]$_.DeviceID) -ieq $rootInstanceID + }) + $signedDriver = $matches[0] + if ($matches.Count -ne 1 -or -not [bool]($signedDriver.IsSigned) -or + ([string]($signedDriver.Signer)) -notmatch '(?i)Microsoft' -or + ([string]($signedDriver.DriverProviderName)) -ine 'USBIP-WIN2' -or + ([string]($signedDriver.InfName)) -ine $udePublishedINF -or + ([string]$root.Service) -ine 'usbip2_ude') { + throw "USB/IP root '$rootInstanceID' lacks one exact signed package identity." + } + $roots.Add([ordered]@{ + instance_id = $rootInstanceID.ToUpperInvariant() + hardware_ids = @(@($root.HardwareID | ForEach-Object { ([string]$_).ToUpperInvariant() }) | + Sort-Object -Unique) + service = [string]$root.Service + provider = [string]($signedDriver.DriverProviderName) + driver_version = [string]($signedDriver.DriverVersion) + published_inf = ([string]($signedDriver.InfName)).ToLowerInvariant() + signer = [string]($signedDriver.Signer) + is_signed = [bool]($signedDriver.IsSigned) + }) + } + return [ordered]@{ + schema = 'viiper.usbip-win2.runtime-provenance/v1' + services = @($services) + root_controllers = @($roots) + } +} + if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { $RepositoryRoot = Join-Path $PSScriptRoot '..\..\..' } @@ -115,6 +251,15 @@ if (-not (Test-IsAdministrator)) { throw 'The source-bound latency gate and WPR capture require an elevated PowerShell session.' } $repository = Resolve-CanonicalPath -Path $RepositoryRoot +$orientationValue = $Orientation.ToLowerInvariant() +$cycleIdValue = $CycleId.ToLowerInvariant() +if (($CycleCount % 2) -ne 0 -or $CycleIndex -gt $CycleCount) { + throw 'CycleCount must be even and CycleIndex must identify one cycle in that balanced set.' +} +$expectedOrientation = if (($CycleIndex % 2) -eq 1) { 'abba' } else { 'baab' } +if ($orientationValue -cne $expectedOrientation) { + throw "Cycle $CycleIndex requires orientation '$expectedOrientation', not '$orientationValue'." +} $gitPath = Resolve-ExactExecutablePath -Path $GitExecutable -Label 'Git executable' $git = [pscustomobject]@{ Source = $gitPath } $gitHash = (Get-FileHash -LiteralPath $gitPath -Algorithm SHA256).Hash.ToLowerInvariant() @@ -155,11 +300,49 @@ if (-not [string]::Equals($actualSDLHash, $SDLBinarySHA256, $signatureGate = Join-Path $repository 'native\udecx\tools\Test-ViiperUdeSignedPackage.ps1' $manifest = Resolve-CanonicalPath -Path $SubmissionManifestPath $manifestHashBeforeGate = (Get-FileHash -LiteralPath $manifest -Algorithm SHA256).Hash.ToLowerInvariant() -& $signatureGate ` - -PackageDirectory $SignedPackageDirectory ` - -SubmissionManifestPath $manifest ` - -ExpectedSourceRevision $ExpectedSourceRevision ` - -ValidationMode Production +$packageModeValue = $PackageValidationMode.ToLowerInvariant().Replace('localtest', 'local-test') +$localTestCertificateHash = '' +$localTestCertificateThumbprint = '' +$signatureArguments = @{ + PackageDirectory = $SignedPackageDirectory + SubmissionManifestPath = $manifest + ExpectedSourceRevision = $ExpectedSourceRevision + ValidationMode = $PackageValidationMode +} +if ($PackageValidationMode -eq 'LocalTest') { + if ([string]::IsNullOrWhiteSpace($LocalTestCertificatePath)) { + throw '-LocalTestCertificatePath is required with -PackageValidationMode LocalTest.' + } + $localTestCertificate = Resolve-CanonicalPath -Path $LocalTestCertificatePath + $localTestCertificateItem = Get-Item -LiteralPath $localTestCertificate -Force -ErrorAction Stop + if ($localTestCertificateItem.PSIsContainer -or + ($localTestCertificateItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $localTestCertificateItem.Length -le 0) { + throw "The local-test certificate is not a non-empty regular file: '$localTestCertificate'." + } + $localTestCertificateHash = (Get-FileHash -LiteralPath $localTestCertificate -Algorithm SHA256).Hash.ToLowerInvariant() + $certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new($localTestCertificate) + try { + $localTestCertificateThumbprint = ([string]$certificate.Thumbprint).ToLowerInvariant() + } + finally { + $certificate.Dispose() + } + if ($localTestCertificateThumbprint -notmatch '^[0-9a-f]{40}$') { + throw 'The local-test certificate has no canonical thumbprint.' + } + $signatureArguments.LocalTestCertificatePath = $localTestCertificate +} +elseif (-not [string]::IsNullOrWhiteSpace($LocalTestCertificatePath)) { + throw '-LocalTestCertificatePath is valid only with -PackageValidationMode LocalTest.' +} +& $signatureGate @signatureArguments +$localTestCertificateArgument = if ([string]::IsNullOrEmpty($localTestCertificateHash)) { + 'none' +} +else { + $localTestCertificateHash +} $packageRoot = Resolve-CanonicalPath -Path $SignedPackageDirectory $packageDriver = Resolve-CanonicalPath -Path (Join-Path $packageRoot 'ViiperUde.sys') @@ -173,6 +356,19 @@ $installedDriverHash = (Get-FileHash -LiteralPath $installedDriver -Algorithm SH if ($packageDriverHash -ne $installedDriverHash) { throw "The installed VIIPER UDE service image does not match the verified package. Installed='$installedDriver'." } +$installedSignature = Get-AuthenticodeSignature -LiteralPath $installedDriver -ErrorAction Stop +if ($PackageValidationMode -eq 'Production') { + if ([string]$installedSignature.Status -cne 'Valid' -or + $null -eq $installedSignature.SignerCertificate -or + [string]$installedSignature.SignerCertificate.Subject -notmatch '(?i)Microsoft') { + throw 'The installed VIIPER UDE image is not validly Microsoft-signed.' + } +} +elseif ([string]$installedSignature.Status -cne 'Valid' -or + $null -eq $installedSignature.SignerCertificate -or + ([string]$installedSignature.SignerCertificate.Thumbprint).ToLowerInvariant() -cne $localTestCertificateThumbprint) { + throw 'The installed VIIPER UDE image is not signed by the exact local-test certificate.' +} $ownedRootDevices = @(Get-CimInstance -ClassName Win32_PnPEntity | Where-Object { @($_.HardwareID) -contains 'ROOT\VIIPER\UDE' }) @@ -186,8 +382,9 @@ $devnodes = @(Get-CimInstance -ClassName Win32_PnPSignedDriver | Where-Object { if ($devnodes.Count -ne 1) { throw "Expected exactly one VIIPER UDE root devnode; found $($devnodes.Count)." } -if (-not [bool]$devnodes[0].IsSigned -or [string]$devnodes[0].Signer -notmatch '(?i)Microsoft') { - throw "The installed VIIPER UDE devnode is not backed by a Microsoft-signed driver (Signer='$($devnodes[0].Signer)')." +if (-not [bool]$devnodes[0].IsSigned -or [string]::IsNullOrWhiteSpace([string]$devnodes[0].Signer) -or + ($PackageValidationMode -eq 'Production' -and [string]$devnodes[0].Signer -notmatch '(?i)Microsoft')) { + throw "The installed VIIPER UDE devnode is not backed by the exact validated package (Signer='$($devnodes[0].Signer)')." } $manifestHash = (Get-FileHash -LiteralPath $manifest -Algorithm SHA256).Hash.ToLowerInvariant() if ($manifestHash -ne $manifestHashBeforeGate) { @@ -198,6 +395,18 @@ $driverBuildIdentity = ([string]$manifestDocument.driverBuildIdentity).Trim().To if ($driverBuildIdentity -notmatch '^[0-9a-f]{64}$') { throw 'The verified submission manifest has no canonical native driver build identity.' } +$usbipRuntime = Get-ExactUSBIPRuntimeProvenance +$usbipRuntimeJSON = ConvertTo-Json -InputObject $usbipRuntime -Depth 8 -Compress +$usbipRuntimeBytes = [Text.UTF8Encoding]::new($false).GetBytes($usbipRuntimeJSON) +$usbipHasher = [Security.Cryptography.SHA256]::Create() +try { + $usbipRuntimeHash = -join @($usbipHasher.ComputeHash($usbipRuntimeBytes) | + ForEach-Object { $_.ToString('x2') }) +} +finally { + $usbipHasher.Dispose() +} +$usbipRuntimeBase64 = [Convert]::ToBase64String($usbipRuntimeBytes) $output = Resolve-NewEvidencePath -Path $OutputPath -Repository $repository -Label 'Latency JSON output' $trace = Resolve-NewEvidencePath -Path $WprTracePath -Repository $repository -Label 'WPR trace output' @@ -241,9 +450,13 @@ $environmentNames = @( 'CGO_ENABLED', 'GOENV', 'GOFLAGS', 'GOTOOLCHAIN', 'GOWORK', 'PATH', 'VIIPER_E2E_LIVE_LATENCY', 'VIIPER_E2E_PRODUCTION_PREFLIGHT', 'VIIPER_E2E_LATENCY_OUTPUT', 'VIIPER_E2E_LATENCY_SAMPLES', + 'VIIPER_E2E_LATENCY_ORIENTATION', 'VIIPER_E2E_LATENCY_CYCLE_ID', + 'VIIPER_E2E_LATENCY_CYCLE_INDEX', 'VIIPER_E2E_LATENCY_CYCLE_COUNT', + 'VIIPER_E2E_USBIP_RUNTIME_PROVENANCE', 'VIIPER_E2E_USBIP_RUNTIME_PROVENANCE_SHA256', 'VIIPER_E2E_EXPECTED_SOURCE_REVISION', 'VIIPER_E2E_SDL_SOURCE_REVISION', 'VIIPER_E2E_SDL_DLL_PATH', 'VIIPER_E2E_SDL_DLL_SHA256', 'VIIPER_E2E_PACKAGE_MANIFEST_SHA256', 'VIIPER_E2E_NATIVE_DRIVER_SHA256', + 'VIIPER_E2E_PACKAGE_VALIDATION_MODE', 'VIIPER_E2E_LOCAL_TEST_CERTIFICATE_SHA256', 'VIIPER_E2E_TRACE_PROFILE_SHA256', 'VIIPER_E2E_NATIVE_DRIVER_BUILD_IDENTITY', 'VIIPER_E2E_EXPECTED_PRIORITY_CLASS', 'VIIPER_E2E_GIT_EXECUTABLE_PATH', 'VIIPER_E2E_GIT_EXECUTABLE_SHA256', @@ -273,11 +486,19 @@ try { $env:VIIPER_E2E_PRODUCTION_PREFLIGHT = '1' $env:VIIPER_E2E_LATENCY_OUTPUT = $output $env:VIIPER_E2E_LATENCY_SAMPLES = [string]$Samples + $env:VIIPER_E2E_LATENCY_ORIENTATION = $orientationValue + $env:VIIPER_E2E_LATENCY_CYCLE_ID = $cycleIdValue + $env:VIIPER_E2E_LATENCY_CYCLE_INDEX = [string]$CycleIndex + $env:VIIPER_E2E_LATENCY_CYCLE_COUNT = [string]$CycleCount + $env:VIIPER_E2E_USBIP_RUNTIME_PROVENANCE = $usbipRuntimeBase64 + $env:VIIPER_E2E_USBIP_RUNTIME_PROVENANCE_SHA256 = $usbipRuntimeHash $env:VIIPER_E2E_EXPECTED_SOURCE_REVISION = $headRevision $env:VIIPER_E2E_SDL_SOURCE_REVISION = $sdlRevision $env:VIIPER_E2E_SDL_DLL_PATH = $sdlDLL $env:VIIPER_E2E_SDL_DLL_SHA256 = $actualSDLHash $env:VIIPER_E2E_PACKAGE_MANIFEST_SHA256 = $manifestHash + $env:VIIPER_E2E_PACKAGE_VALIDATION_MODE = $packageModeValue + $env:VIIPER_E2E_LOCAL_TEST_CERTIFICATE_SHA256 = $localTestCertificateHash $env:VIIPER_E2E_NATIVE_DRIVER_SHA256 = $installedDriverHash $env:VIIPER_E2E_TRACE_PROFILE_SHA256 = $wprProfileHash $env:VIIPER_E2E_NATIVE_DRIVER_BUILD_IDENTITY = $driverBuildIdentity @@ -373,13 +594,16 @@ if (-not (Test-Path -LiteralPath $output -PathType Leaf)) { throw "The latency gate exited successfully without the required JSON artifact '$output'." } $report = Get-Content -LiteralPath $output -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop -if ([string]$report.schema -cne 'viiper.controller-to-game.latency-suite/v2' -or +if ([string]$report.schema -cne 'viiper.controller-to-game.latency-suite/v3' -or [string]$report.provenance.source_revision -cne $headRevision -or [string]$report.provenance.sdl_source_revision -cne $sdlRevision -or [string]$report.provenance.sdl_binary_sha256 -cne $actualSDLHash -or [string]$report.provenance.native_package_manifest_sha256 -cne $manifestHash -or + [string]$report.provenance.native_package_validation_mode -cne $packageModeValue -or + [string]$report.provenance.native_local_test_certificate_sha256 -cne $localTestCertificateHash -or [string]$report.provenance.native_driver_sha256 -cne $installedDriverHash -or [string]$report.provenance.native_driver_build_identity -cne $driverBuildIdentity -or + [string]$report.provenance.usbip_runtime.capture_sha256 -cne $usbipRuntimeHash -or [string]$report.provenance.git_executable_path -cne $gitPath -or [string]$report.provenance.git_executable_sha256 -cne $gitHash -or [string]$report.provenance.go_executable_path -cne $goPath -or @@ -492,7 +716,19 @@ if ($traceMarkers.Count -ne $expectedMarkers.Count) { $missingMarkers = @($expectedMarkers.Keys | Where-Object { -not $traceMarkers.ContainsKey($_) }) throw "The ETL has $($traceMarkers.Count) exact sample markers for $($expectedMarkers.Count) JSON samples; missing: $($missingMarkers -join ', ')." } -$markerJSON = ConvertTo-Json -InputObject @($decodedMarkers) -Depth 3 -Compress +$traceItem = Get-Item -LiteralPath $trace -Force -ErrorAction Stop +if ($traceItem.PSIsContainer -or + ($traceItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $traceItem.Length -le 0) { + throw "The raw WPR evidence is not a non-empty regular file: '$trace'." +} +$markerEnvelope = [ordered]@{ + schema = 'viiper.controller-to-game.latency-trace-markers/v1' + source_trace_length = [long]$traceItem.Length + source_trace_sha256 = (Get-FileHash -LiteralPath $traceItem.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + markers = @($decodedMarkers) +} +$markerJSON = ConvertTo-Json -InputObject $markerEnvelope -Depth 4 -Compress $markerBytes = [Text.UTF8Encoding]::new($false).GetBytes($markerJSON) $markerStream = [IO.File]::Open($markers, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) try { @@ -513,13 +749,21 @@ try { ./_testing/e2e/cmd/verifylatency ` -input $output ` -markers $markers ` + -trace $trace ` -source $headRevision ` -sdl-revision $sdlRevision ` -sdl-sha256 $actualSDLHash ` -manifest-sha256 $manifestHash ` + -package-validation-mode $packageModeValue ` + -local-test-certificate-sha256 $localTestCertificateArgument ` -driver-sha256 $installedDriverHash ` -driver-build-identity $driverBuildIdentity ` -trace-profile-sha256 $wprProfileHash ` + -usbip-runtime-sha256 $usbipRuntimeHash ` + -orientation $orientationValue ` + -cycle-id $cycleIdValue ` + -cycle-index $CycleIndex ` + -cycle-count $CycleCount ` -samples $Samples 2>&1) $verifyExitCode = $LASTEXITCODE } @@ -532,18 +776,28 @@ if ($verifyExitCode -ne 0) { throw "The strict Go evidence verifier rejected the JSON/ETL evidence pair.`n$($verifyOutput -join [Environment]::NewLine)" } $requiredControllers = @('xbox360', 'dualshock4', 'dualsensegamepadv5') +$expectedTransports = if ($orientationValue -ceq 'abba') { + @('usbip', 'native-ude', 'native-ude', 'usbip') +} +else { + @('native-ude', 'usbip', 'usbip', 'native-ude') +} for ($index = 0; $index -lt $requiredControllers.Count; $index++) { $case = $report.cases[$index] if ([string]$case.workload.controller_type -cne $requiredControllers[$index] -or [int]$case.workload.warmup_pairs -ne 16 -or [int]$case.workload.sample_pairs -ne $Samples -or + [string]$case.workload.schedule_orientation -cne $orientationValue -or + [string]$case.workload.cycle_id -cne $cycleIdValue -or + [int]$case.workload.cycle_index -ne $CycleIndex -or + [int]$case.workload.cycle_count -ne $CycleCount -or [long]$case.workload.inter_transition_delay_ns -ne 2000000 -or [string]$case.workload.phase_sweep_sha256 -cne '21eee9ea71984343ebd21221df8272553d6ab369a5740a1c796380cd468abcd9' -or @($case.runs).Count -ne 4 -or - [string]$case.runs[0].transport -cne 'usbip' -or - [string]$case.runs[1].transport -cne 'native-ude' -or - [string]$case.runs[2].transport -cne 'native-ude' -or - [string]$case.runs[3].transport -cne 'usbip' -or + [string]$case.runs[0].transport -cne $expectedTransports[0] -or + [string]$case.runs[1].transport -cne $expectedTransports[1] -or + [string]$case.runs[2].transport -cne $expectedTransports[2] -or + [string]$case.runs[3].transport -cne $expectedTransports[3] -or [int]$case.runs[0].order -ne 1 -or [int]$case.runs[0].transport_block -ne 1 -or [int]$case.runs[1].order -ne 2 -or [int]$case.runs[1].transport_block -ne 1 -or [int]$case.runs[2].order -ne 3 -or [int]$case.runs[2].transport_block -ne 2 -or diff --git a/_testing/e2e/scripts/Invoke-ViiperE2ELatencyMatrix.ps1 b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyMatrix.ps1 index 6346f413..541a8b30 100644 --- a/_testing/e2e/scripts/Invoke-ViiperE2ELatencyMatrix.ps1 +++ b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyMatrix.ps1 @@ -6,6 +6,11 @@ param( [Parameter(Mandatory = $true)] [string]$SubmissionManifestPath, + [ValidateSet('Production', 'LocalTest')] + [string]$PackageValidationMode = 'Production', + + [string]$LocalTestCertificatePath, + [Parameter(Mandatory = $true)] [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] [string]$ExpectedSourceRevision, @@ -20,6 +25,9 @@ param( [ValidateRange(256, 10000)] [int]$Samples = 10000, + [ValidateRange(6, 20)] + [int]$CyclesPerPriority = 8, + [string]$RepositoryRoot, [Parameter(Mandatory = $true)] @@ -60,21 +68,49 @@ if (-not $matrixRootItem.PSIsContainer -or $gate = Join-Path $PSScriptRoot 'Invoke-ViiperE2ELatencyGate.ps1' $gate = (Resolve-Path -LiteralPath $gate -ErrorAction Stop).Path $matrixPath = Join-Path $matrixRoot 'viiper-latency-priority-matrix.json' -$runs = @( - [pscustomobject]@{ - priority = 'Normal' - report = (Join-Path $matrixRoot 'viiper-latency-normal.json') - trace = (Join-Path $matrixRoot 'viiper-latency-normal.etl') - }, - [pscustomobject]@{ - priority = 'High' - report = (Join-Path $matrixRoot 'viiper-latency-high.json') - trace = (Join-Path $matrixRoot 'viiper-latency-high.etl') +$superiorityPath = Join-Path $matrixRoot 'viiper-latency-superiority.json' +if (($CyclesPerPriority % 2) -ne 0) { + throw 'CyclesPerPriority must be even so each priority has equal ABBA and BAAB cycles.' +} +if ($PackageValidationMode -eq 'LocalTest' -and + [string]::IsNullOrWhiteSpace($LocalTestCertificatePath)) { + throw '-LocalTestCertificatePath is required with -PackageValidationMode LocalTest.' +} +if ($PackageValidationMode -eq 'Production' -and + -not [string]::IsNullOrWhiteSpace($LocalTestCertificatePath)) { + throw '-LocalTestCertificatePath is valid only with -PackageValidationMode LocalTest.' +} +$cycleBytes = [byte[]]::new(16) +$random = [Security.Cryptography.RandomNumberGenerator]::Create() +try { + $random.GetBytes($cycleBytes) +} +finally { + $random.Dispose() +} +$cycleId = -join @($cycleBytes | ForEach-Object { $_.ToString('x2') }) +$cycleCount = 2 * $CyclesPerPriority +$runs = [Collections.Generic.List[object]]::new() +$cycleIndex = 0 +foreach ($priority in @('Normal', 'High')) { + for ($priorityCycle = 1; $priorityCycle -le $CyclesPerPriority; $priorityCycle++) { + $cycleIndex++ + $orientation = if (($cycleIndex % 2) -eq 1) { 'ABBA' } else { 'BAAB' } + $stem = "viiper-latency-$($priority.ToLowerInvariant())-cycle-$($priorityCycle.ToString('00'))" + $runs.Add([pscustomobject]@{ + priority = $priority + priority_cycle = $priorityCycle + cycle_index = $cycleIndex + orientation = $orientation + report = (Join-Path $matrixRoot "$stem.json") + trace = (Join-Path $matrixRoot "$stem.etl") + }) } -) +} $allOutputs = [Collections.Generic.List[string]]::new() $allOutputs.Add($matrixPath) +$allOutputs.Add($superiorityPath) foreach ($run in $runs) { $allOutputs.Add([string]$run.report) $allOutputs.Add([string]$run.trace) @@ -89,6 +125,7 @@ foreach ($path in $allOutputs) { $common = @{ SignedPackageDirectory = $SignedPackageDirectory SubmissionManifestPath = $SubmissionManifestPath + PackageValidationMode = $PackageValidationMode ExpectedSourceRevision = $ExpectedSourceRevision SDLBinarySHA256 = $SDLBinarySHA256 Samples = $Samples @@ -98,26 +135,38 @@ $common = @{ if (-not [string]::IsNullOrWhiteSpace($RepositoryRoot)) { $common.RepositoryRoot = $RepositoryRoot } +if (-not [string]::IsNullOrWhiteSpace($LocalTestCertificatePath)) { + $common.LocalTestCertificatePath = $LocalTestCertificatePath +} foreach ($run in $runs) { & $gate @common ` -OutputPath $run.report ` -WprTracePath $run.trace ` - -PriorityClass $run.priority + -PriorityClass $run.priority ` + -Orientation $run.orientation ` + -CycleId $cycleId ` + -CycleIndex $run.cycle_index ` + -CycleCount $cycleCount } $matrixRuns = [Collections.Generic.List[object]]::new() $referenceProvenance = $null +$matrixPackageIdentity = $null foreach ($run in $runs) { $reportFile = Get-ExactEvidenceFile -Path $run.report -Label "$($run.priority) report" $traceFile = Get-ExactEvidenceFile -Path $run.trace -Label "$($run.priority) trace" $markerFile = Get-ExactEvidenceFile -Path "$($run.report).etl-markers.json" -Label "$($run.priority) markers" $report = Get-Content -LiteralPath $run.report -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop $expectedPriority = ([string]$run.priority).ToLowerInvariant() - if ([string]$report.schema -cne 'viiper.controller-to-game.latency-suite/v2' -or + if ([string]$report.schema -cne 'viiper.controller-to-game.latency-suite/v3' -or [string]$report.verdict -cne 'pass' -or [string]$report.provenance.source_revision -cne $ExpectedSourceRevision.ToLowerInvariant() -or [string]$report.provenance.machine.process_priority_class -cne $expectedPriority -or + [string]$report.cases[0].workload.schedule_orientation -cne $run.orientation.ToLowerInvariant() -or + [string]$report.cases[0].workload.cycle_id -cne $cycleId -or + [int]$report.cases[0].workload.cycle_index -ne [int]$run.cycle_index -or + [int]$report.cases[0].workload.cycle_count -ne $cycleCount -or @($report.cases).Count -ne 3) { throw "$($run.priority) report is not an exact passing priority-bound suite." } @@ -129,6 +178,8 @@ foreach ($run in $runs) { [string]$report.provenance.sdl_binary_path, [string]$report.provenance.sdl_binary_sha256, [string]$report.provenance.native_package_manifest_sha256, + [string]$report.provenance.native_package_validation_mode, + [string]$report.provenance.native_local_test_certificate_sha256, [string]$report.provenance.native_driver_sha256, [string]$report.provenance.native_driver_build_identity, [string]$report.provenance.qpc_frequency, @@ -137,6 +188,7 @@ foreach ($run in $runs) { [string]$report.provenance.trace_profile_sha256, [string]$report.provenance.usbip_baseline_mode, [string]$report.provenance.usbip_baseline_version, + [string]$report.provenance.usbip_runtime.capture_sha256, [string]$report.provenance.go_version, [string]$report.provenance.goos, [string]$report.provenance.goarch, @@ -156,6 +208,13 @@ foreach ($run in $runs) { ) -join "`n" if ($null -eq $referenceProvenance) { $referenceProvenance = $provenanceIdentity + $matrixPackageIdentity = [ordered]@{ + native_package_manifest_sha256 = [string]$report.provenance.native_package_manifest_sha256 + native_package_validation_mode = [string]$report.provenance.native_package_validation_mode + native_local_test_certificate_sha256 = [string]$report.provenance.native_local_test_certificate_sha256 + native_driver_sha256 = [string]$report.provenance.native_driver_sha256 + native_driver_build_identity = [string]$report.provenance.native_driver_build_identity + } } elseif (-not [string]::Equals($referenceProvenance, $provenanceIdentity, [StringComparison]::Ordinal)) { @@ -164,6 +223,9 @@ foreach ($run in $runs) { $matrixRuns.Add([ordered]@{ priority_class = $expectedPriority + priority_cycle = [int]$run.priority_cycle + cycle_index = [int]$run.cycle_index + orientation = $run.orientation.ToLowerInvariant() report = $reportFile trace = $traceFile decoded_markers = $markerFile @@ -171,9 +233,17 @@ foreach ($run in $runs) { } $matrix = [ordered]@{ - schema = 'viiper.controller-to-game.latency-priority-matrix/v1' + schema = 'viiper.controller-to-game.latency-priority-matrix/v2' generated_at = [DateTime]::UtcNow.ToString('o') source_revision = $ExpectedSourceRevision.ToLowerInvariant() + native_package_manifest_sha256 = $matrixPackageIdentity.native_package_manifest_sha256 + native_package_validation_mode = $matrixPackageIdentity.native_package_validation_mode + native_local_test_certificate_sha256 = $matrixPackageIdentity.native_local_test_certificate_sha256 + native_driver_sha256 = $matrixPackageIdentity.native_driver_sha256 + native_driver_build_identity = $matrixPackageIdentity.native_driver_build_identity + cycle_id = $cycleId + cycle_count = $cycleCount + cycles_per_priority = $CyclesPerPriority sample_pairs_per_transition = $Samples runs = @($matrixRuns) } @@ -190,4 +260,51 @@ finally { } $matrixFile = Get-ExactEvidenceFile -Path $matrixPath -Label 'priority matrix manifest' +$repository = if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { + (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..\..') -ErrorAction Stop).Path +} +else { + (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).Path +} +$goPath = (Resolve-Path -LiteralPath $GoExecutable -ErrorAction Stop).Path +$expectedSource = $ExpectedSourceRevision.ToLowerInvariant() +$analyzerEnvironment = @{} +foreach ($name in @('CGO_ENABLED', 'GOENV', 'GOFLAGS', 'GOTOOLCHAIN', 'GOWORK')) { + $analyzerEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') +} +try { + $env:CGO_ENABLED = '0' + $env:GOENV = 'off' + $env:GOFLAGS = '' + $env:GOTOOLCHAIN = 'local' + $env:GOWORK = 'off' + $verifyOutput = @(& $goPath -C $repository run -buildvcs=false -mod=readonly ` + ./_testing/e2e/cmd/verifylatencymatrix ` + -input $matrixPath ` + -output $superiorityPath ` + -source $expectedSource 2>&1) + $verifyExitCode = $LASTEXITCODE +} +finally { + foreach ($name in $analyzerEnvironment.Keys) { + [Environment]::SetEnvironmentVariable($name, $analyzerEnvironment[$name], 'Process') + } +} +if ($verifyExitCode -ne 0) { + throw ("Native latency was not lower in every observed balanced matrix cycle. " + + "The failure artifact, if structurally valid, is '$superiorityPath'.`n" + + ($verifyOutput -join [Environment]::NewLine)) +} +$superiorityFile = Get-ExactEvidenceFile -Path $superiorityPath -Label 'latency superiority evidence' +$superiority = Get-Content -LiteralPath $superiorityPath -Raw -ErrorAction Stop | + ConvertFrom-Json -ErrorAction Stop +if ([string]$superiority.schema -cne 'viiper.controller-to-game.latency-superiority-evidence/v1' -or + [string]$superiority.verdict -cne 'pass' -or + [string]$superiority.analysis.verdict -cne 'pass' -or + [string]$superiority.analysis.cycle_id -cne $cycleId -or + [int]$superiority.analysis.cycle_count -ne $cycleCount -or + [int]$superiority.analysis.cycles_per_priority -ne $CyclesPerPriority) { + throw 'The strict Go analyzer returned a contradictory superiority artifact.' +} Write-Host "Validated normal/high-priority latency matrix: '$($matrixFile.path)' (SHA-256 $($matrixFile.sha256))." +Write-Host "Observed native latency lower in every balanced cycle for this exact machine session: '$($superiorityFile.path)' (SHA-256 $($superiorityFile.sha256))." diff --git a/docs/testing/e2e_latency.md b/docs/testing/e2e_latency.md index 0c82a9c2..d7e0368a 100644 --- a/docs/testing/e2e_latency.md +++ b/docs/testing/e2e_latency.md @@ -11,9 +11,9 @@ must not be presented as interchangeable evidence. compares authenticated USB/IP and native UDE runs, and emits a strict JSON evidence artifact plus a source-controlled sequential-file WPR trace. - `_testing/e2e/scripts/Invoke-ViiperE2ELatencyMatrix.ps1` is the release - entry point. It runs the complete gate once at Normal and once at High - process priority, then binds both raw JSON/ETL/decoded-marker sets into one - hash manifest. + entry point. By default it runs eight alternating ABBA/BAAB cycles at Normal + priority and eight more at High priority, then binds every raw + JSON/ETL/decoded-marker set into one hash manifest and descriptive result. No live latency result is checked into this document. A passing result exists only when the production command below succeeds on the stated machine and its @@ -23,15 +23,20 @@ source-bound artifacts are retained. This is an exact-source native-path, production-authentic API-to-consumer gate. The USB/IP comparator is deliberately labeled -`version-probed-functional-baseline-not-source-bound`: the wrapper proves the -supported 0.9.7.7 command and functional port contract, not the source revision -of that third-party installed driver. The Go -test starts `cmd.Server` in process at the clean `HEAD` under test and uses the -repository's Go client over real localhost TCP. Beyond that process boundary it -uses the installed USB/IP or native UDE transport, the actual Windows controller -stack, and the source-bound SDL DLL. Authentication, API framing, controller -serialization, transport delivery, HID consumption, SDL event delivery, and -consumer wake-up are therefore live rather than mocked. +`exact-installed-usbip-win2-runtime-and-source-bound-server`. Before every +cycle the wrapper captures the exact installed `usbip2_filter` and +`usbip2_ude` service images, driver-store and published INF bytes, catalog and +SYS hashes, Microsoft signatures and signer thumbprints, plus the exact +`ROOT\USBIP_WIN2\UDE` controller inventory. That canonical provenance is +hashed, embedded in the report, and required to remain identical throughout +the matrix. This proves the exact installed comparator bytes, not their +unavailable third-party source revision. The Go test starts `cmd.Server` in +process at the clean `HEAD` under test and uses the repository's Go client over +real localhost TCP. Beyond that process boundary it uses the installed USB/IP +or native UDE transport, the actual Windows controller stack, and the +source-bound SDL DLL. Authentication, API framing, controller serialization, +transport delivery, HID consumption, SDL event delivery, and consumer wake-up +are therefore live rather than mocked. It is not a packaged-executable, service/task-hosted broker, DS4Windows, physical controller, display, or game-engine-frame test. The signed-package/broker live @@ -51,16 +56,17 @@ button states, and waits for the corresponding game-facing SDL transition: | `dualsensegamepadv5` | PS5 | `054c:0ce6` | Cross | Each controller uses a fresh server, bus, device, stream, and exact SDL binding -for four counterbalanced blocks: USB/IP, native UDE, native UDE, USB/IP (ABBA). +for four counterbalanced blocks. Odd cycles use USB/IP, native UDE, native UDE, +USB/IP (ABBA); even cycles reverse that order (BAAB). Sixteen unrecorded press/release pairs warm the complete path at the start of every block. The declared sample count is then split as evenly as possible between the two blocks for each transport; `-Samples 256` therefore records 128 pairs in each block and aggregates 256 press plus 256 release samples per -transport. ABBA makes both the first/last positions USB/IP and both middle -positions native, reducing one-way warm-up and monotonic-drift bias without -discarding per-block source identity. +transport. Alternating ABBA and BAAB gives each transport every block position +equally within each priority stratum, reducing order, warm-up, and +monotonic-drift bias without discarding per-block source identity. -The v2 JSON retains every raw sample and publishes nearest-rank p50, p90, p95, +The v3 JSON retains every raw sample and publishes nearest-rank p50, p90, p95, p99, p99.9, and max values plus population jitter. Its provenance includes the host name, Windows product/display/build identity, CPU model, logical processor count, token elevation, and the measured process priority class. Reports from @@ -70,7 +76,7 @@ All four blocks use the same API address, credential, bus/device position, input sequence, warm-up count, one-second event timeout, and deterministic unmeasured dwell schedule. Xbox success cannot certify either PlayStation path. Missing, ambiguous, or misidentified DualShock 4 or DualSense enumeration—or a -failure in any ABBA block—fails the whole suite. +failure in any ABBA/BAAB block—fails the whole suite. A fixed 2 ms dwell could repeatedly land writes at the same phase of a 1 ms HID service interval. The gate instead retains a 2 ms minimum state dwell and adds @@ -198,18 +204,28 @@ transport performance. The gate does not say native is lower latency unless the retained live artifact actually shows negative native-minus-USB/IP deltas. The parser rejects unknown fields, trailing JSON, weakened absolute or -same-machine limits, a non-ABBA schedule, mixed transport proof, workload drift +same-machine limits, a schedule that contradicts the cycle-bound ABBA/BAAB +orientation, mixed transport proof, workload drift between controllers, reordered or missing press/release samples, and block, aggregate, comparison, or verdict fields that do not exactly recompute from the individual records. +The matrix gate adds a stricter descriptive condition: for Normal and High +priority, native mean, p95, and p99 must each be lower than USB/IP for press and +release on every controller in every observed balanced cycle. It retains every +cycle delta and the worst cycle. The result is deliberately scoped to that +exact source-bound machine session. Back-to-back cycles are not claimed to be +independent, and the artifact makes no 95% confidence, population, other-host, +or future-run inference. A pass therefore means “native was lower in every +observed matrix cycle,” not “native is universally faster.” + ## Running the production gate Prerequisites are an elevated Windows PowerShell session, an exact clean checkout, Go 1.26 or newer, CGO with a working C toolchain, CMake, the source-built SDL submodule, WPR, USB/IP win2 0.9.7.7, and an already installed Microsoft-signed VIIPER UDE package matching -its submission manifest. +its submission manifest. Production validation is the default. The SDL wrapper currently links the multi-configuration Debug output. Build and record that exact binary before running the gate: @@ -223,8 +239,8 @@ $sdlHash = (Get-FileHash .\_testing\e2e\deps\SDL\build\Debug\SDL3.dll -Algorithm Choose an existing evidence directory outside the checkout. Existing files are never overwritten. `-Samples` is the total pair count per controller/transport and is bounded to 256–10,000. The release matrix defaults -to 10,000 and produces independent Normal/High JSON, ETL, and decoded-marker -artifacts. +to 10,000 pairs and eight counterbalanced cycles per priority, producing unique +cycle-bound Normal/High JSON, ETL, and decoded-marker artifacts. ```powershell $revision = (git rev-parse HEAD).Trim() @@ -242,11 +258,25 @@ $goExe = 'C:\Go\bin\go.exe' -Samples 10000 ``` +On a disposable Windows 11 test laptop, the same source-bound matrix may be +run against an exact local-test package by adding both explicit arguments: + +```powershell + -PackageValidationMode LocalTest ` + -LocalTestCertificatePath C:\ViiperUde\ViiperUdeTest.cer +``` + +LocalTest mode passes the package through VIIPER's dedicated local-test +signature gate, requires the installed SYS to be signed by that exact +certificate, and records the certificate SHA-256 and non-production validation +mode in every report. It cannot satisfy or be relabeled as the default +Microsoft production-signature mode. + For a single diagnostic run, call `Invoke-ViiperE2ELatencyGate.ps1` directly with `-PriorityClass Normal` or `-PriorityClass High` and new `-OutputPath` and `-WprTracePath` values. Both wrappers require absolute, non-reparse Git and Go executable paths; the system WPR image is pinned automatically. Their paths and -SHA-256 values are retained in the v2 provenance. A single run is not the +SHA-256 values are retained in the v3 provenance. A single run is not the release priority matrix. The wrapper verifies and uses the checked-in `ViiperLatency.wprp` in sequential @@ -259,9 +289,11 @@ the ETL oldest-first and requires exact chronological, one-to-one marker and QPC/timestamp/latency payload equality with the strictly parsed JSON; missing, duplicate, reordered, extra, or undecodable markers fail closed. The exact decoded marker set is retained beside the JSON as -`.etl-markers.json`, and the production wrapper invokes the same Go -strict parser/recomputation verifier used by deterministic tests on the JSON, -decoded-marker, and ETL evidence pair. +`.etl-markers.json`. Its strict envelope records the source ETL's +exact length and SHA-256, preventing a decoded marker stream from being paired +with a swapped raw trace. The production wrapper invokes the same Go strict +parser/recomputation verifier used by deterministic tests on the JSON, +decoded-marker, and ETL evidence triple. The ETL remains corroborating scheduler evidence, not a substitute for SDL's consumer timestamp. From 9481f9dbfde64af99905fa325546e50b5ea03d6e Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 15 Aug 2026 21:45:34 -0500 Subject: [PATCH 2/4] fix: preserve atomic crash state writes in PowerShell --- .github/workflows/native-ude.yml | 6 ++ .../tools/Set-ViiperCrashDiagnostics.ps1 | 4 +- .../Test-ViiperCrashDiagnosticsContract.ps1 | 85 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 native/udecx/tools/Test-ViiperCrashDiagnosticsContract.ps1 diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index c8e82943..16687fd6 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -218,6 +218,12 @@ jobs: } } if ($failed) { throw "Windows PowerShell 5.1 parser gate failed" } + - name: Test crash-diagnostic state writer + shell: pwsh + run: ./native/udecx/tools/Test-ViiperCrashDiagnosticsContract.ps1 + - name: Test crash-diagnostic state writer with Windows PowerShell 5.1 + shell: powershell + run: ./native/udecx/tools/Test-ViiperCrashDiagnosticsContract.ps1 - name: Expose WDK tools shell: pwsh run: | diff --git a/native/udecx/tools/Set-ViiperCrashDiagnostics.ps1 b/native/udecx/tools/Set-ViiperCrashDiagnostics.ps1 index fd195e91..83f62b01 100644 --- a/native/udecx/tools/Set-ViiperCrashDiagnostics.ps1 +++ b/native/udecx/tools/Set-ViiperCrashDiagnostics.ps1 @@ -90,7 +90,9 @@ function Write-StateFile { $temporary = "$fullPath.tmp" [IO.File]::WriteAllText($temporary, ($State | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) - [IO.File]::Replace($temporary, $fullPath, $null, $true) + [IO.File]::Replace( + $temporary, $fullPath, + [Management.Automation.Language.NullString]::Value, $true) } function Write-NewStateFile { diff --git a/native/udecx/tools/Test-ViiperCrashDiagnosticsContract.ps1 b/native/udecx/tools/Test-ViiperCrashDiagnosticsContract.ps1 new file mode 100644 index 00000000..f938bd12 --- /dev/null +++ b/native/udecx/tools/Test-ViiperCrashDiagnosticsContract.ps1 @@ -0,0 +1,85 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptPath = Join-Path $PSScriptRoot 'Set-ViiperCrashDiagnostics.ps1' +$scriptItem = Get-Item -LiteralPath $scriptPath -Force -ErrorAction Stop +if ($scriptItem.PSIsContainer -or + ($scriptItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $scriptItem.Length -le 0) { + throw "Crash-diagnostic script is unsafe or empty: '$scriptPath'." +} + +$tokens = $null +$parseErrors = $null +$ast = [Management.Automation.Language.Parser]::ParseFile( + $scriptItem.FullName, [ref]$tokens, [ref]$parseErrors) +if (@($parseErrors).Count -ne 0) { + throw "Crash-diagnostic script parse failed: $(@($parseErrors | ForEach-Object Message) -join '; ')" +} + +$writers = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Write-StateFile' + }, $true)) +if ($writers.Count -ne 1) { + throw "Expected exactly one Write-StateFile definition; found $($writers.Count)." +} +$writerSource = $writers[0].Extent.Text +if ($writerSource -notmatch + '\[Management\.Automation\.Language\.NullString\]::Value') { + throw 'Existing crash-policy state replacement must pass a true CLR null backup path.' +} + +# Load only the state-writer function. This avoids every registry, pagefile, +# dump-policy, privilege, and reboot path in the production script. +. ([ScriptBlock]::Create($writerSource)) + +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ( + 'viiper-crash-writer-contract-' + [Guid]::NewGuid().ToString('N')) +[void][IO.Directory]::CreateDirectory($temporaryRoot) +try { + $StatePath = Join-Path $temporaryRoot 'crash-policy-backup.json' + [IO.File]::WriteAllText( + $StatePath, '{"schema":1,"sequence":0}', + [Text.UTF8Encoding]::new($false)) + + foreach ($sequence in 1..16) { + Write-StateFile -State ([ordered]@{ + schema = 1 + machine = 'contract-machine' + sequence = $sequence + }) + } + + $state = Get-Content -LiteralPath $StatePath -Raw -Encoding UTF8 | + ConvertFrom-Json -ErrorAction Stop + if ([int]$state.schema -ne 1 -or [int]$state.sequence -ne 16 -or + [string]$state.machine -cne 'contract-machine') { + throw 'Crash-policy state replacement did not publish the final state.' + } + $bytes = [IO.File]::ReadAllBytes($StatePath) + if ($bytes.Length -eq 0 -or + ($bytes.Length -ge 3 -and $bytes[0] -eq 0xef -and + $bytes[1] -eq 0xbb -and $bytes[2] -eq 0xbf)) { + throw 'Crash-policy state replacement must remain non-empty UTF-8 without a BOM.' + } + if (Test-Path -LiteralPath "$StatePath.tmp") { + throw 'Crash-policy state replacement retained its temporary file.' + } +} +finally { + $resolvedTemporary = [IO.Path]::GetFullPath($temporaryRoot) + $systemTemporary = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + if ($resolvedTemporary.StartsWith( + $systemTemporary, [StringComparison]::OrdinalIgnoreCase) -and + [IO.Path]::GetFileName($resolvedTemporary) -like + 'viiper-crash-writer-contract-*') { + Remove-Item -LiteralPath $resolvedTemporary -Recurse -Force + } +} + +Write-Host 'VIIPER crash-diagnostic atomic state-writer contract passed.' From 06ae1ba5a52f2bbcf7de74f9d46f46bb7880870c Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 16 Aug 2026 02:25:15 -0500 Subject: [PATCH 3/4] fix: harden native package trust recovery --- cmd/viiper/privileged_bootstrap_test.go | 311 +++ cmd/viiper/viiper.go | 183 +- internal/cmd/install.go | 16 +- internal/cmd/install_linux.go | 8 +- internal/cmd/install_windows.go | 10 +- internal/cmd/native_package.go | 156 +- internal/cmd/native_package_contract_test.go | 170 ++ internal/cmd/native_package_recover.go | 505 +++++ .../cmd/native_package_recover_nonwindows.go | 17 + internal/cmd/native_package_recover_test.go | 331 ++++ .../cmd/native_package_recover_windows.go | 1482 ++++++++++++++ .../native_package_recover_windows_test.go | 412 ++++ internal/cmd/native_package_test.go | 73 + internal/cmd/native_package_uninstall.go | 104 +- .../native_package_uninstall_contract_test.go | 143 ++ internal/cmd/native_package_uninstall_test.go | 218 ++- .../cmd/native_package_uninstall_windows.go | 397 +++- .../native_package_uninstall_windows_test.go | 79 + internal/cmd/native_package_windows.go | 1726 ++++++++++++++++- internal/cmd/native_package_windows_test.go | 381 ++++ internal/config/config.go | 1 + .../udecx/local_test_package_contract_test.go | 435 ++--- .../tools/Install-ViiperUdeLocalTest.ps1 | 806 +++++--- .../tools/New-ViiperUdeLocalTestPackage.ps1 | 6 +- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 40 + native/udecx/tools/ViiperUdeCtl.cpp | 139 +- 26 files changed, 7326 insertions(+), 823 deletions(-) create mode 100644 cmd/viiper/privileged_bootstrap_test.go create mode 100644 internal/cmd/native_package_recover.go create mode 100644 internal/cmd/native_package_recover_nonwindows.go create mode 100644 internal/cmd/native_package_recover_test.go create mode 100644 internal/cmd/native_package_recover_windows.go create mode 100644 internal/cmd/native_package_recover_windows_test.go create mode 100644 internal/cmd/native_package_uninstall_contract_test.go diff --git a/cmd/viiper/privileged_bootstrap_test.go b/cmd/viiper/privileged_bootstrap_test.go new file mode 100644 index 00000000..f0ff57bc --- /dev/null +++ b/cmd/viiper/privileged_bootstrap_test.go @@ -0,0 +1,311 @@ +package main + +import ( + "bytes" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Alia5/VIIPER/internal/config" + "github.com/alecthomas/kong" +) + +func TestClassifyBootstrapSealsPrivilegedLifecycleCommands(t *testing.T) { + t.Parallel() + + for _, command := range []string{ + "install", + "uninstall", + "native-package-install", + "native-package-broker-commit", + "native-package-recover", + } { + command := command + t.Run(command, func(t *testing.T) { + t.Parallel() + mode, err := classifyBootstrap([]string{"--help", command}) + if err != nil { + t.Fatalf("classifyBootstrap() error = %v", err) + } + if mode != bootstrapPrivilegedLifecycle { + t.Fatalf("classifyBootstrap() mode = %v, want privileged", mode) + } + }) + } +} + +func TestClassifyBootstrapRejectsPrivilegedGlobalInjection(t *testing.T) { + t.Parallel() + + tests := [][]string{ + {"--config", `C:\attacker\config.json`, "native-package-recover"}, + {"--config=C:\\attacker\\config.json", "native-package-install"}, + {"--update-notify", "prerelease", "install"}, + {"native-package-broker-commit", "--update-notify=stable"}, + {"--log.level", "trace", "uninstall"}, + {"-l", "trace", "native-package-install"}, + {"-ltrace", "native-package-recover"}, + {"native-package-recover", "--log.file", `C:\protected.log`}, + {"native-package-recover", "--log.raw-file=C:\\protected.raw"}, + } + for _, args := range tests { + args := args + t.Run(strings.Join(args, "_"), func(t *testing.T) { + t.Parallel() + mode, err := classifyBootstrap(args) + if mode != bootstrapPrivilegedLifecycle { + t.Fatalf("classifyBootstrap(%q) mode = %v, want privileged", args, mode) + } + if err == nil { + t.Fatalf("classifyBootstrap(%q) unexpectedly allowed configurable global option", args) + } + }) + } +} + +func TestClassifyBootstrapRejectsPrivilegedCommandAliasesAndCase(t *testing.T) { + t.Parallel() + + for _, command := range []string{ + "Install", "UNINSTALL", "Native-Package-Install", + "NATIVE-PACKAGE-BROKER-COMMIT", "native-package-RECOVER", + } { + mode, err := classifyBootstrap([]string{command}) + if mode != bootstrapPrivilegedLifecycle || err == nil { + t.Errorf("classifyBootstrap(%q) = (%v, %v), want privileged rejection", command, mode, err) + } + } +} + +func TestClassifyBootstrapPreservesOrdinaryDispatch(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{ + {"server"}, + {"service", "run"}, + {"proxy", "--upstream", "native-package-recover"}, + // The protected-looking token is the value of --config, not a command. + {"--config", "native-package-recover", "server"}, + } { + mode, err := classifyBootstrap(args) + if err != nil || mode != bootstrapStandard { + t.Errorf("classifyBootstrap(%q) = (%v, %v), want standard", args, mode, err) + } + } +} + +func TestPrivilegedLifecycleParserPreservesCapabilityArguments(t *testing.T) { + t.Parallel() + + values := map[string]string{ + "helper": `C:\stage\ViiperUdeCtl.exe`, + "certificate": `C:\stage\ViiperUdeTest.cer`, + "authorization": `C:\stage\failed-install-recovery-progress.json`, + "capability": `C:\stage\failed-install-recovery-capability.json`, + } + var cli privilegedLifecycleCLI + parser, err := kong.New(&cli, privilegedLifecycleKongOptions()...) + if err != nil { + t.Fatal(err) + } + hash := strings.Repeat("a", 64) + revision := strings.Repeat("b", 40) + ctx, err := parser.Parse([]string{ + "native-package-recover", + "--driver-helper", values["helper"], + "--expected-helper-sha-256", hash, + "--certificate-path", values["certificate"], + "--expected-certificate-sha-256", hash, + "--recovery-authorization", values["authorization"], + "--expected-recovery-authorization-sha-256", hash, + "--recovery-root-authorization-sha-256", hash, + "--source-revision", revision, + "--recovery-capability", values["capability"], + "--expected-recovery-capability-sha-256", hash, + "--current-package-lock-sha-256", hash, + "--current-bundle-manifest-sha-256", hash, + }) + if err != nil { + t.Fatal(err) + } + if ctx.Command() != "native-package-recover" { + t.Fatalf("command = %q", ctx.Command()) + } + if cli.NativePackageRecover.DriverHelper != values["helper"] || + cli.NativePackageRecover.CertificatePath != values["certificate"] || + cli.NativePackageRecover.RecoveryAuthorization != values["authorization"] || + cli.NativePackageRecover.RecoveryCapability != values["capability"] || + cli.NativePackageRecover.ExpectedRecoveryCapabilitySHA256 != hash { + t.Fatalf("command-specific recovery arguments changed during sealed parse: %+v", cli.NativePackageRecover) + } +} + +func TestPrivilegedLifecycleLoggerIgnoresEnvironmentPoisoning(t *testing.T) { + directory := t.TempDir() + logPath := filepath.Join(directory, "poisoned.log") + rawPath := filepath.Join(directory, "poisoned.raw") + for _, path := range []string{logPath, rawPath} { + if err := os.WriteFile(path, []byte("sentinel"), 0o600); err != nil { + t.Fatal(err) + } + } + t.Setenv("VIIPER_LOG_LEVEL", "trace") + t.Setenv("VIIPER_LOG_FILE", logPath) + t.Setenv("VIIPER_LOG_RAW_FILE", rawPath) + t.Setenv("VIIPER_UPDATE_NOTIFY", "prerelease") + + previous := slog.Default() + t.Cleanup(func() { slog.SetDefault(previous) }) + logger, closers, err := setupPrivilegedLifecycleLogger() + if err != nil { + t.Fatal(err) + } + defer closeLogFiles(closers) + if len(closers) != 0 { + t.Fatalf("privileged logger opened %d files", len(closers)) + } + logger.Info("sealed bootstrap logger probe") + for _, path := range []string{logPath, rawPath} { + content, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + if string(content) != "sentinel" { + t.Fatalf("poisoned path %q was created, appended, or truncated: %q", path, content) + } + } +} + +func TestPrivilegedLifecycleDisablesUpdater(t *testing.T) { + t.Parallel() + + for _, notify := range []config.UpdateNotify{ + config.UpdateNotifyStable, + config.UpdateNotifyPrerelease, + } { + if shouldStartUpdateNotifier(bootstrapPrivilegedLifecycle, "install", notify) { + t.Fatalf("privileged lifecycle enabled updater for %q", notify) + } + } + if shouldStartUpdateNotifier(bootstrapStandard, "service run", config.UpdateNotifyStable) { + t.Fatal("service command enabled updater") + } + if !shouldStartUpdateNotifier(bootstrapStandard, "server", config.UpdateNotifyStable) { + t.Fatal("ordinary server lost its update notifier") + } +} + +func TestPrivilegedBootstrapPrecedesConfigLogAndUpdaterSource(t *testing.T) { + t.Parallel() + + _, sourcePath, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate test source") + } + source, err := os.ReadFile(filepath.Join(filepath.Dir(sourcePath), "viiper.go")) + if err != nil { + t.Fatal(err) + } + text := string(source) + mainStart := strings.Index(text, "func main()") + mainEnd := strings.Index(text, "type bootstrapMode") + if mainStart < 0 || mainEnd <= mainStart { + t.Fatal("locate main bootstrap source") + } + mainBody := text[mainStart:mainEnd] + classify := strings.Index(mainBody, "classifyBootstrap(os.Args[1:])") + plainHelp := strings.Index(mainBody, "handlePlainHelpFlag()") + standard := strings.Index(mainBody, "runStandard()") + if classify < 0 || plainHelp < 0 || standard < 0 || classify > plainHelp || classify > standard { + t.Fatalf("raw privileged classifier is not the first bootstrap decision:\n%s", mainBody) + } + + sealedStart := strings.Index(text, "func runPrivilegedLifecycle()") + sealedEnd := strings.Index(text, "func runStandard()") + if sealedStart < 0 || sealedEnd <= sealedStart { + t.Fatal("locate privileged lifecycle source") + } + sealedBody := text[sealedStart:sealedEnd] + for _, forbidden := range []string{ + "findUserConfig", "ConfigCandidatePaths", "kong.Configuration", + "setupRawLogger", "cli.Log", "updater.", "CheckUpdate", + } { + if strings.Contains(sealedBody, forbidden) { + t.Errorf("privileged lifecycle source contains forbidden bootstrap dependency %q", forbidden) + } + } +} + +func TestPrivilegedLifecycleSubprocessIgnoresConfigAndLogEnvironment(t *testing.T) { + if testing.Short() { + t.Skip("subprocess build disabled in short mode") + } + directory := t.TempDir() + binary := filepath.Join(directory, "viiper-bootstrap-test") + if runtime.GOOS == "windows" { + binary += ".exe" + } + build := exec.Command("go", "build", "-o", binary, ".") + buildOutput, err := build.CombinedOutput() + if err != nil { + t.Fatalf("build subprocess fixture: %v\n%s", err, buildOutput) + } + + configDir := filepath.Join(directory, "VIIPER") + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatal(err) + } + invalidConfig := filepath.Join(configDir, "config.json") + if err := os.WriteFile(invalidConfig, []byte("{not-json"), 0o600); err != nil { + t.Fatal(err) + } + logPath := filepath.Join(directory, "must-not-exist.log") + rawPath := filepath.Join(directory, "must-not-exist.raw") + updatePath := filepath.Join(configDir, "update-dismissed") + + hash := strings.Repeat("a", 64) + revision := strings.Repeat("b", 40) + command := exec.Command(binary, + "native-package-recover", + "--driver-helper", "relative-helper.exe", + "--expected-helper-sha-256", hash, + "--certificate-path", "relative-certificate.cer", + "--expected-certificate-sha-256", hash, + "--recovery-authorization", "relative-authorization.json", + "--expected-recovery-authorization-sha-256", hash, + "--recovery-root-authorization-sha-256", hash, + "--source-revision", revision, + "--recovery-capability", "relative-capability.json", + "--expected-recovery-capability-sha-256", hash, + "--current-package-lock-sha-256", hash, + "--current-bundle-manifest-sha-256", hash, + ) + command.Env = append(os.Environ(), + fmt.Sprintf("APPDATA=%s", directory), + fmt.Sprintf("VIIPER_CONFIG=%s", invalidConfig), + "VIIPER_LOG_LEVEL=trace", + fmt.Sprintf("VIIPER_LOG_FILE=%s", logPath), + fmt.Sprintf("VIIPER_LOG_RAW_FILE=%s", rawPath), + "VIIPER_UPDATE_NOTIFY=prerelease", + ) + output, err := command.CombinedOutput() + if err == nil { + t.Fatalf("invalid recovery fixture unexpectedly succeeded: %s", output) + } + if !bytes.Contains(output, []byte("driver helper must be an absolute path")) { + t.Fatalf("sealed subprocess did not reach command-specific validation:\n%s", output) + } + if bytes.Contains(output, []byte("config")) && bytes.Contains(output, []byte("not-json")) { + t.Fatalf("sealed subprocess consulted poisoned config:\n%s", output) + } + for _, path := range []string{logPath, rawPath, updatePath} { + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("sealed subprocess created or updated %q (stat error %v)", path, statErr) + } + } +} diff --git a/cmd/viiper/viiper.go b/cmd/viiper/viiper.go index 57590595..04d77c3c 100644 --- a/cmd/viiper/viiper.go +++ b/cmd/viiper/viiper.go @@ -9,6 +9,7 @@ import ( "strings" "time" + viipercmd "github.com/Alia5/VIIPER/internal/cmd" "github.com/Alia5/VIIPER/internal/config" "github.com/Alia5/VIIPER/internal/configpaths" "github.com/Alia5/VIIPER/internal/log" @@ -23,7 +24,75 @@ import ( ) func main() { + bootstrap, err := classifyBootstrap(os.Args[1:]) + if err != nil { + fmt.Fprintln(os.Stderr, "refusing privileged lifecycle bootstrap:", err) + os.Exit(2) + } + handlePlainHelpFlag() + if bootstrap == bootstrapPrivilegedLifecycle { + runPrivilegedLifecycle() + return + } + runStandard() +} + +type bootstrapMode uint8 + +const ( + bootstrapStandard bootstrapMode = iota + bootstrapPrivilegedLifecycle +) + +// privilegedLifecycleCLI intentionally contains no configurable global fields. +// These commands can be launched elevated from a user-controlled environment; +// parsing them through config.CLI would consult env-backed config, log, and +// updater settings before their command-specific authorization checks run. +type privilegedLifecycleCLI struct { + Install viipercmd.Install `cmd:"" help:"Add the current VIIPER executable to system startup and runs it (creates a Systemd service on Linux)"` + Uninstall viipercmd.Uninstall `cmd:"" help:"Remove any VIIPER system startup configuration / Systemd service"` + NativePackageInstall viipercmd.NativePackageInstall `cmd:"" name:"native-package-install" help:"Install a verified native UDE package and broker transactionally" hidden:""` + NativePackageRecover viipercmd.NativePackageRecover `cmd:"" name:"native-package-recover" help:"Reconcile only retained native package journals" hidden:""` + NativePackageBrokerCommit viipercmd.NativePackageBrokerCommit `cmd:"" name:"native-package-broker-commit" help:"Commit the broker inside an active native package transaction" hidden:""` +} + +func runPrivilegedLifecycle() { + var cli privilegedLifecycleCLI + ctx := kong.Parse(&cli, privilegedLifecycleKongOptions()...) + + // A privileged lifecycle command never opens a configured file logger or + // raw packet logger. The fixed console logger is independent of argv, env, + // user config, and default config locations. + logger, closeFiles, err := setupPrivilegedLifecycleLogger() + if err != nil { + fmt.Fprintln(os.Stderr, "failed to setup privileged lifecycle console logger:", err) + os.Exit(2) + } + defer closeLogFiles(closeFiles) + + rawLogger := log.NewRaw(nil) + ctx.Bind(logger) + ctx.BindTo(rawLogger, (*log.RawLogger)(nil)) + + err = ctx.Run() + ctx.FatalIfErrorf(err) +} + +func privilegedLifecycleKongOptions() []kong.Option { + return []kong.Option{ + kong.Name("VIIPER"), + kong.Description(Description()), + kong.UsageOnError(), + kong.Help(kong.DefaultHelpPrinter), + } +} + +func setupPrivilegedLifecycleLogger() (*slog.Logger, []io.Closer, error) { + return log.SetupLogger("info", "") +} + +func runStandard() { userCfg := findUserConfig(os.Args[1:]) jsonPaths, yamlPaths, tomlPaths := configpaths.ConfigCandidatePaths(userCfg) @@ -45,21 +114,15 @@ func main() { fmt.Fprintln(os.Stderr, "failed to setup logger:", err) os.Exit(2) } - defer func() { - for _, c := range closeFiles { - _ = c.Close() - } - }() - rawLogger := setupRawLogger(&cli, logger, &closeFiles) + defer closeLogFiles(closeFiles) ctx.Bind(logger) ctx.BindTo(rawLogger, (*log.RawLogger)(nil)) // A broker hosted by Service Control Manager has no interactive desktop. // Update UI belongs to DS4Windows/the package installer, never session 0. - isServiceCommand := strings.HasPrefix(ctx.Command(), "service") - if !isServiceCommand && cli.UpdateNotify != config.UpdateNotifyNone { + if shouldStartUpdateNotifier(bootstrapStandard, ctx.Command(), cli.UpdateNotify) { go func() { time.Sleep(10 * time.Second) updater.CheckUpdate(Version, cli.UpdateNotify) @@ -73,6 +136,110 @@ func main() { ctx.FatalIfErrorf(err) } +func shouldStartUpdateNotifier( + bootstrap bootstrapMode, + command string, + notify config.UpdateNotify, +) bool { + return bootstrap == bootstrapStandard && + !strings.HasPrefix(command, "service") && + notify != config.UpdateNotifyNone +} + +func closeLogFiles(closeFiles []io.Closer) { + for _, closer := range closeFiles { + _ = closer.Close() + } +} + +func classifyBootstrap(args []string) (bootstrapMode, error) { + command := rawCommand(args) + canonical, protected := privilegedLifecycleCommand(command) + if !protected { + return bootstrapStandard, nil + } + if command != canonical { + return bootstrapPrivilegedLifecycle, fmt.Errorf( + "command %q must use canonical spelling %q", command, canonical, + ) + } + for _, arg := range args { + if option, forbidden := privilegedBootstrapForbiddenOption(arg); forbidden { + return bootstrapPrivilegedLifecycle, fmt.Errorf( + "%s is not allowed for command %q", option, canonical, + ) + } + } + return bootstrapPrivilegedLifecycle, nil +} + +func rawCommand(args []string) string { + for position := 0; position < len(args); position++ { + arg := args[position] + if arg == "--" { + if position+1 < len(args) { + return args[position+1] + } + return "" + } + if option, consumesNext := configurableGlobalOption(arg); option != "" { + if consumesNext && position+1 < len(args) { + position++ + } + continue + } + if strings.HasPrefix(arg, "-") { + continue + } + return arg + } + return "" +} + +func configurableGlobalOption(arg string) (name string, consumesNext bool) { + lower := strings.ToLower(arg) + for _, option := range []string{ + "--config", "--update-notify", "--log.level", "--log.file", "--log.raw-file", + } { + if lower == option { + return option, true + } + if strings.HasPrefix(lower, option+"=") { + return option, false + } + } + if lower == "-l" { + return "-l", true + } + if strings.HasPrefix(lower, "-l=") || (strings.HasPrefix(lower, "-l") && len(lower) > 2) { + return "-l", false + } + return "", false +} + +func privilegedBootstrapForbiddenOption(arg string) (string, bool) { + option, _ := configurableGlobalOption(arg) + if option == "" { + return "", false + } + return option, true +} + +func privilegedLifecycleCommand(command string) (canonical string, protected bool) { + for _, candidate := range []string{ + "install", + "uninstall", + "native-package-install", + "native-package-broker-commit", + "native-package-recover", + } { + if strings.EqualFold(command, candidate) { + return candidate, true + } + } + return "", false +} + func handlePlainHelpFlag() { for i, arg := range os.Args[1:] { if arg == "-p" { diff --git a/internal/cmd/install.go b/internal/cmd/install.go index a43a70b1..e10ec65b 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -21,10 +21,14 @@ type Install struct { // Uninstall removes VIIPER's platform-owned service/startup state. Production // Windows packages also remove their exact native devnode and Driver Store package. type Uninstall struct { - Yes bool `help:"Confirm removal without prompting." short:"y"` - TargetUserSID string `help:"Interactive Windows user SID that owns VIIPER startup state." hidden:""` - DriverHelper string `help:"Path to the packaged ViiperUdeCtl.exe used for exact native package removal." hidden:""` - ExpectedHelperSHA256 string `help:"Installer-embedded SHA-256 of ViiperUdeCtl.exe used for exact native package removal." hidden:""` + Yes bool `help:"Confirm removal without prompting." short:"y"` + TargetUserSID string `help:"Interactive Windows user SID that owns VIIPER startup state." hidden:""` + DriverHelper string `help:"Path to the packaged ViiperUdeCtl.exe used for exact native package removal." hidden:""` + ExpectedHelperSHA256 string `help:"Installer-embedded SHA-256 of ViiperUdeCtl.exe used for exact native package removal." hidden:""` + SourceRevision string `help:"Reviewed source revision that owns local-test trust." hidden:""` + LocalTestCertificatePath string `help:"Path to the exact local-test certificate whose ownership must be settled." hidden:""` + ExpectedLocalTestCertificateSHA256 string `help:"SHA-256 of the exact local-test certificate whose ownership must be settled." hidden:""` + ExpectedLocalTestPackageLockSHA256 string `help:"SHA-256 of the package lock that owns local-test trust." hidden:""` } func (c *Install) Run(logger *slog.Logger) error { @@ -73,6 +77,10 @@ func (c *Uninstall) Run(logger *slog.Logger) error { strings.TrimSpace(c.TargetUserSID), strings.TrimSpace(c.DriverHelper), strings.ToLower(strings.TrimSpace(c.ExpectedHelperSHA256)), + strings.ToLower(strings.TrimSpace(c.SourceRevision)), + strings.TrimSpace(c.LocalTestCertificatePath), + strings.ToLower(strings.TrimSpace(c.ExpectedLocalTestCertificateSHA256)), + strings.ToLower(strings.TrimSpace(c.ExpectedLocalTestPackageLockSHA256)), ) } diff --git a/internal/cmd/install_linux.go b/internal/cmd/install_linux.go index a8d1f397..ffdead10 100644 --- a/internal/cmd/install_linux.go +++ b/internal/cmd/install_linux.go @@ -53,7 +53,9 @@ func install(logger *slog.Logger, transport, targetUserSID string) error { func uninstall( logger *slog.Logger, - targetUserSID, driverHelper, expectedHelperSHA256 string, + targetUserSID, driverHelper, expectedHelperSHA256, sourceRevision, + localTestCertificatePath, expectedLocalTestCertificateSHA256, + expectedLocalTestPackageLockSHA256 string, ) error { if targetUserSID != "" { return errors.New("--target-user-sid is supported only by the Windows native broker installer") @@ -61,6 +63,10 @@ func uninstall( if driverHelper != "" || expectedHelperSHA256 != "" { return errors.New("native package uninstall helper inputs are supported only on Windows") } + if sourceRevision != "" || localTestCertificatePath != "" || + expectedLocalTestCertificateSHA256 != "" || expectedLocalTestPackageLockSHA256 != "" { + return errors.New("local-test native package trust cleanup is supported only on Windows") + } var errs []error if err := runSystemctl("stop", serviceName); err != nil { diff --git a/internal/cmd/install_windows.go b/internal/cmd/install_windows.go index 7b2338be..27e390dc 100644 --- a/internal/cmd/install_windows.go +++ b/internal/cmd/install_windows.go @@ -146,11 +146,17 @@ func requireNativeUDEBroker() error { func uninstall( logger *slog.Logger, - targetUserSID, driverHelper, expectedHelperSHA256 string, + targetUserSID, driverHelper, expectedHelperSHA256, sourceRevision, + localTestCertificatePath, expectedLocalTestCertificateSHA256, + expectedLocalTestPackageLockSHA256 string, ) error { request := nativePackageUninstallRequest{ driverHelper: driverHelper, expectedHelperSHA256: expectedHelperSHA256, - targetUserSID: targetUserSID, + targetUserSID: targetUserSID, + sourceRevision: sourceRevision, + localTestCertificatePath: localTestCertificatePath, + expectedLocalTestCertificateSHA256: expectedLocalTestCertificateSHA256, + expectedLocalTestPackageLockSHA256: expectedLocalTestPackageLockSHA256, } if err := request.validate(); err != nil { return err diff --git a/internal/cmd/native_package.go b/internal/cmd/native_package.go index 5f84f7a8..4c9698cb 100644 --- a/internal/cmd/native_package.go +++ b/internal/cmd/native_package.go @@ -39,6 +39,11 @@ type nativePackageRebootRequiredError struct { cause error } +type nativePackageInstallExitError struct { + cause error + exitCode int +} + type nativePackageRecoveryRetryError struct{} func (*nativePackageRecoveryRetryError) Error() string { @@ -57,24 +62,37 @@ func (e *nativePackageRebootRequiredError) ExitCode() int { return nativePackageRebootRequiredCode } +func (e *nativePackageInstallExitError) Error() string { return e.cause.Error() } +func (e *nativePackageInstallExitError) Unwrap() error { return e.cause } + +// ExitCode preserves the helper's authenticated, settled failure class for +// the signed outer installer. Flattening (for example) a preflight exit 4 to +// process exit 1 makes exact trust rollback impossible to classify safely. +func (e *nativePackageInstallExitError) ExitCode() int { return e.exitCode } + // NativePackageInstall is the narrow bootstrapper boundary for the native UDE // package. Production is the default and normal users enter through the signed // DS4Windows installer. The explicit local-test route retains the same hashes, // rollback, service, and authenticated health transaction for disposable // TESTSIGNING machines without relaxing the production route. type NativePackageInstall struct { - PackageDirectory string `help:"Directory containing the exact Microsoft-returned INF, SYS, and CAT runtime files." required:""` - SubmissionManifest string `help:"Source-bound HLK/WHCP submission manifest." required:""` - SourceRevision string `help:"Reviewed 40- or 64-character source revision." required:""` - DriverHelper string `help:"Path to the packaged ViiperUdeCtl.exe." required:""` - ExpectedBrokerSHA256 string `help:"Installer-embedded SHA-256 of this VIIPER executable." required:""` - ExpectedHelperSHA256 string `help:"Installer-embedded SHA-256 of ViiperUdeCtl.exe." required:""` - ExpectedManifestSHA256 string `help:"Installer-embedded SHA-256 of the reviewed HLK/WHCP manifest." required:""` - ExpectedInfSHA256 string `help:"Installer-embedded SHA-256 of the Microsoft-returned ViiperUde.inf." required:""` - ExpectedSysSHA256 string `help:"Installer-embedded SHA-256 of the Microsoft-returned ViiperUde.sys." required:""` - ExpectedCatSHA256 string `help:"Installer-embedded SHA-256 of the Microsoft-returned ViiperUde.cat." required:""` - TargetUserSID string `help:"Interactive Windows user SID that owns legacy startup state." required:""` - DriverValidationMode string `help:"Driver signature route: production or local-test." default:"production" enum:"production,local-test" hidden:""` + PackageDirectory string `help:"Directory containing the exact Microsoft-returned INF, SYS, and CAT runtime files." required:""` + SubmissionManifest string `help:"Source-bound HLK/WHCP submission manifest." required:""` + SourceRevision string `help:"Reviewed 40- or 64-character source revision." required:""` + DriverHelper string `help:"Path to the packaged ViiperUdeCtl.exe." required:""` + ExpectedBrokerSHA256 string `help:"Installer-embedded SHA-256 of this VIIPER executable." required:""` + ExpectedHelperSHA256 string `help:"Installer-embedded SHA-256 of ViiperUdeCtl.exe." required:""` + ExpectedManifestSHA256 string `help:"Installer-embedded SHA-256 of the reviewed HLK/WHCP manifest." required:""` + ExpectedInfSHA256 string `help:"Installer-embedded SHA-256 of the Microsoft-returned ViiperUde.inf." required:""` + ExpectedSysSHA256 string `help:"Installer-embedded SHA-256 of the Microsoft-returned ViiperUde.sys." required:""` + ExpectedCatSHA256 string `help:"Installer-embedded SHA-256 of the Microsoft-returned ViiperUde.cat." required:""` + TargetUserSID string `help:"Interactive Windows user SID that owns legacy startup state." required:""` + DriverValidationMode string `help:"Driver signature route: production or local-test." default:"production" enum:"production,local-test" hidden:""` + LocalTestTrustCapability string `help:"Protected parent-bound local-test trust capability."` + ExpectedTrustCapabilitySHA256 string `help:"SHA-256 of the protected local-test trust capability."` + LocalTestCertificatePath string `help:"Path to the exact source-bound local-test certificate."` + ExpectedLocalTestCertificateSHA256 string `help:"Package-lock SHA-256 of the exact local-test certificate."` + ExpectedLocalTestPackageLockSHA256 string `help:"Out-of-band SHA-256 of the local-test package lock."` } // NativePackageBrokerCommit is invoked only by ViiperUdeCtl while the signed @@ -433,19 +451,24 @@ func (c *NativePackageInstall) Run(logger *slog.Logger) error { return errors.New("cannot provision the native package from 'go run'") } request := nativePackageRequest{ - brokerSource: executable, - packageDirectory: strings.TrimSpace(c.PackageDirectory), - submissionManifest: strings.TrimSpace(c.SubmissionManifest), - sourceRevision: strings.ToLower(strings.TrimSpace(c.SourceRevision)), - driverHelper: strings.TrimSpace(c.DriverHelper), - expectedBrokerSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedBrokerSHA256)), - expectedHelperSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedHelperSHA256)), - expectedManifestSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedManifestSHA256)), - expectedInfSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedInfSHA256)), - expectedSysSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedSysSHA256)), - expectedCatSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedCatSHA256)), - targetUserSID: strings.TrimSpace(c.TargetUserSID), - driverValidationMode: strings.ToLower(strings.TrimSpace(c.DriverValidationMode)), + brokerSource: executable, + packageDirectory: strings.TrimSpace(c.PackageDirectory), + submissionManifest: strings.TrimSpace(c.SubmissionManifest), + sourceRevision: strings.ToLower(strings.TrimSpace(c.SourceRevision)), + driverHelper: strings.TrimSpace(c.DriverHelper), + expectedBrokerSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedBrokerSHA256)), + expectedHelperSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedHelperSHA256)), + expectedManifestSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedManifestSHA256)), + expectedInfSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedInfSHA256)), + expectedSysSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedSysSHA256)), + expectedCatSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedCatSHA256)), + targetUserSID: strings.TrimSpace(c.TargetUserSID), + driverValidationMode: strings.ToLower(strings.TrimSpace(c.DriverValidationMode)), + localTestTrustCapability: strings.TrimSpace(c.LocalTestTrustCapability), + expectedTrustCapabilitySHA256: strings.ToLower(strings.TrimSpace(c.ExpectedTrustCapabilitySHA256)), + localTestCertificatePath: strings.TrimSpace(c.LocalTestCertificatePath), + expectedLocalTestCertificateSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedLocalTestCertificateSHA256)), + expectedLocalTestPackageLockSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedLocalTestPackageLockSHA256)), } if err := request.validate(); err != nil { return err @@ -456,19 +479,50 @@ func (c *NativePackageInstall) Run(logger *slog.Logger) error { } type nativePackageRequest struct { - brokerSource string - packageDirectory string - submissionManifest string - sourceRevision string - driverHelper string - expectedBrokerSHA256 string - expectedHelperSHA256 string - expectedManifestSHA256 string - expectedInfSHA256 string - expectedSysSHA256 string - expectedCatSHA256 string - targetUserSID string - driverValidationMode string + brokerSource string + packageDirectory string + submissionManifest string + sourceRevision string + driverHelper string + expectedBrokerSHA256 string + expectedHelperSHA256 string + expectedManifestSHA256 string + expectedInfSHA256 string + expectedSysSHA256 string + expectedCatSHA256 string + targetUserSID string + driverValidationMode string + localTestTrustCapability string + expectedTrustCapabilitySHA256 string + localTestCertificatePath string + expectedLocalTestCertificateSHA256 string + expectedLocalTestPackageLockSHA256 string +} + +// nativePackageProductionLocalTrustAdmission classifies the machine-global +// local-test ownership journal while Trust and Package are held, but before +// Service can be acquired. Production may retire one validated terminal +// settlement; every live state belongs to a local-test transaction and is a +// hard stop rather than topology-cleanup authority. +func nativePackageProductionLocalTrustAdmission(states []string) (bool, error) { + if len(states) == 0 { + return false, nil + } + if len(states) != 1 { + return false, errors.New("multiple local-test trust ownership states block production install") + } + switch states[0] { + case "cleared": + return true, nil + case "preparing", "pending", "owned", "uninstalling": + return false, fmt.Errorf( + "active local-test trust %s state blocks production install", states[0], + ) + default: + return false, fmt.Errorf( + "unknown local-test trust %q state blocks production install", states[0], + ) + } } func (r nativePackageRequest) validate() error { @@ -490,6 +544,32 @@ func (r nativePackageRequest) validate() error { if r.driverValidationMode != "production" && r.driverValidationMode != "local-test" { return errors.New("native package driver validation mode must be production or local-test") } + if r.driverValidationMode == "local-test" { + if r.localTestTrustCapability == "" || + r.localTestCertificatePath == "" || + !nativePackageSHA256.MatchString(r.expectedTrustCapabilitySHA256) || + !nativePackageSHA256.MatchString(r.expectedLocalTestCertificateSHA256) || + !nativePackageSHA256.MatchString(r.expectedLocalTestPackageLockSHA256) { + return errors.New("native local-test package requires an exact parent trust capability, certificate SHA-256, and package-lock SHA-256") + } + for name, path := range map[string]string{ + "trust capability": r.localTestTrustCapability, + "certificate": r.localTestCertificatePath, + } { + if strings.IndexByte(path, 0) >= 0 || !filepath.IsAbs(path) { + return fmt.Errorf("native local-test %s must be an absolute path without NUL", name) + } + } + if !strings.EqualFold(filepath.Base(r.localTestCertificatePath), "ViiperUdeTest.cer") { + return errors.New("native local-test certificate must be named ViiperUdeTest.cer") + } + } else if r.localTestTrustCapability != "" || + r.expectedTrustCapabilitySHA256 != "" || + r.localTestCertificatePath != "" || + r.expectedLocalTestCertificateSHA256 != "" || + r.expectedLocalTestPackageLockSHA256 != "" { + return errors.New("production native package requests must not carry local-test trust capability fields") + } if !nativePackageSHA256.MatchString(r.expectedBrokerSHA256) || !nativePackageSHA256.MatchString(r.expectedHelperSHA256) || !nativePackageSHA256.MatchString(r.expectedManifestSHA256) || diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index 48bf32dc..65aaec2c 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -64,13 +64,97 @@ func TestNativePackageProductionSourceContract(t *testing.T) { "removeWeakExactOwnedService", "restoreQuiescedPriorService", "driverHelperSettled", "nativePackageRebootRequiredError", + "nativePackageInstallExitError", + "exitCode: proof.exitCode", "parseNativePackageInstallProof(text, processExitCode)", + "initializeNativePackageRecoveryTrustLease", + "acquireNativePackageRecoveryTrustLease", + "requireNativePackageTrustRecoveryClear", + "verifyLocalTestTrustCapability", + "nativePackageParentIdentity", + "decodeCanonicalNativePackageLocalTestTrustOwnership", + "nativePackageLocalTrustPendingName", + "nativePackageLocalTrustOwnedName", + "nativePackageLocalTrustUninstallingName", + "nativePackageLocalTrustClearedName", + "publishNativePackageLocalTestTrustPreparing", + "transitionNativePackageLocalTestTrustRecord", + "restoreNativePackageLocalTestTrustStores", + "inspectNativePackageLocalTestTrust", + "countExactNativePackageLocalTestCertificateRejectingThumbprintCollisions", + "observedThumbprint == expectedThumbprint", + "different certificate with the same Windows SHA-1 thumbprint", + "proveNativePackageLocalTestTopologyAbsent", + "commitLocalTestTrust", + "capability.ParentPID != parentPID", + "capability.ParentCreationFileTime != parentCreationFileTime", + "capability.TrustJournalSchema != nativePackageLocalTestTrustOwnershipSchema", } for _, fragment := range requiredWindows { if !strings.Contains(windowsSource, fragment) { t.Errorf("Windows package orchestrator lost %q", fragment) } } + leaseStart := strings.Index(windowsSource, + "func initializeNativePackageRecoveryTrustLease() error {") + leaseEnd := strings.Index(windowsSource, + "func resolveNativePackageLocalTestTrustPaths()") + if leaseStart < 0 || leaseEnd <= leaseStart { + t.Fatal("native fixed trust-lease initializer is missing or malformed") + } + leasePublication := windowsSource[leaseStart:leaseEnd] + leaseWrite := strings.Index(leasePublication, "windows.WriteFile(lease, marker") + leaseFlush := strings.Index(leasePublication, "windows.FlushFileBuffers(lease)") + leaseClose := strings.Index(leasePublication, + "if closeErr := windows.CloseHandle(lease); closeErr != nil {") + leaseReopen := strings.Index(leasePublication, "lockNativePackageInput(temporary)") + leaseReadback := strings.Index(leasePublication, + "readNativePackageRecoveryFile(prepublish, 1)") + leasePublish := strings.Index(leasePublication, + "moveNativePackageFile(temporary, paths.lease, false)") + if leaseWrite < 0 || leaseFlush <= leaseWrite || leaseClose <= leaseFlush || + leaseReopen <= leaseClose || leaseReadback <= leaseReopen || + leasePublish <= leaseReadback { + t.Fatal("native fixed trust lease is published before protected flush, close, reopen, and exact readback") + } + for _, fragment := range []string{ + "rand.Read(nonce[:])", "windows.CREATE_NEW", "windows.FILE_FLAG_WRITE_THROUGH", + "validateNativeFileLinkCount(information.NumberOfLinks)", + "validateNativeSecurityDescriptor(", + "!bytes.Equal(readback, []byte{1})", + } { + if !strings.Contains(leasePublication, fragment) { + t.Errorf("native fixed trust-lease publication lost %q", fragment) + } + } + preparingStart := strings.Index(windowsSource, + "func publishNativePackageLocalTestTrustPreparing(") + preparingEnd := strings.Index(windowsSource, + "func transitionNativePackageLocalTestTrustRecord(") + if preparingStart < 0 || preparingEnd <= preparingStart { + t.Fatal("native local-test preparing publication is missing or malformed") + } + preparingPublication := windowsSource[preparingStart:preparingEnd] + prepareScratch := strings.Index(preparingPublication, + "createExactNativePackageRecoveryPreparation(temporary, contents)") + preparePublish := strings.Index(preparingPublication, + "moveNativePackageFile(temporary, path, false)") + if prepareScratch < 0 || preparePublish <= prepareScratch || + !strings.Contains(preparingPublication, "rand.Read(nonce[:])") { + t.Fatal("local-test preparing authority is not scratch-written and atomically no-replace published") + } + for _, fragment := range []string{ + "LocalTestTrustCapability", + "ExpectedTrustCapabilitySHA256", + "LocalTestCertificatePath", + "ExpectedLocalTestCertificateSHA256", + "ExpectedLocalTestPackageLockSHA256", + "production native package requests must not carry local-test trust capability fields", + } { + if !strings.Contains(transactionSource, fragment) { + t.Errorf("native package command lost parent-bound local-test field %q", fragment) + } + } stageStart := strings.Index(windowsSource, "func (t *windowsNativePackageTransaction) stageCoordinationToken() error {") stageEnd := strings.Index(windowsSource, @@ -1170,6 +1254,92 @@ func TestNativePackageProductionSourceContract(t *testing.T) { } } +func TestNativePackageCrossModeInstallAdmissionSourceContract(t *testing.T) { + t.Parallel() + windowsSource := readNativePackageContractFile(t, "native_package_windows.go") + preflightStart := strings.Index(windowsSource, + "func (t *windowsNativePackageTransaction) Preflight(ctx context.Context) error {") + preflightEnd := strings.Index(windowsSource, + "func (t *windowsNativePackageTransaction) verifyLocalTestTrustCapability() error {") + if preflightStart < 0 || preflightEnd <= preflightStart { + t.Fatal("native package outer preflight source region is missing or malformed") + } + preflight := windowsSource[preflightStart:preflightEnd] + trustInitialize := strings.Index(preflight, "initializeNativePackageRecoveryTrustLease()") + trustAcquire := strings.Index(preflight, + "acquireNativePackageRecoveryTrustLease(ctx, trustDeadline)") + packageAcquire := strings.Index(preflight, + "acquireNamedNativePackageMutex(nativePackageMutexName, packageBudget)") + recoveryAdmission := strings.Index(preflight, "requireNativePackageTrustRecoveryClear()") + localAdmission := strings.Index(preflight, "t.admitProductionLocalTestTrust()") + if trustInitialize < 0 || trustAcquire <= trustInitialize || + packageAcquire <= trustAcquire || recoveryAdmission <= packageAcquire || + localAdmission <= recoveryAdmission { + t.Fatal("outer install no longer orders Trust -> Package -> failed-recovery/local-trust admission") + } + if strings.Contains(preflight[:packageAcquire], "driverValidationMode") { + t.Fatal("outer install still conditions Trust or Package acquisition on driver validation mode") + } + if strings.Contains(preflight, "acquireNativeInstallMutex(") { + t.Fatal("outer install acquired Service before completing production local-trust admission") + } + + admissionStart := strings.Index(windowsSource, + "func (t *windowsNativePackageTransaction) admitProductionLocalTestTrust() error {") + admissionEnd := strings.Index(windowsSource, + "func (t *windowsNativePackageTransaction) openLocalTestTrustStores()") + if admissionStart < 0 || admissionEnd <= admissionStart { + t.Fatal("production local-test trust admission source region is missing or malformed") + } + admission := windowsSource[admissionStart:admissionEnd] + for _, fragment := range []string{ + "t.releaseTrustLease == nil || t.releaseMutex == nil", + "t.releaseServiceMutex != nil", + `{state: "preparing", path: paths.preparing}`, + `{state: "pending", path: paths.pending}`, + `{state: "owned", path: paths.owned}`, + `{state: "uninstalling", path: paths.uninstalling}`, + `{state: "cleared", path: paths.cleared}`, + "readNativePackageLocalTestTrustRecord(candidate.path)", + "nativePackageProductionLocalTrustAdmission(states)", + "retireNativePackageLocalTestTrustRecord(", + } { + if !strings.Contains(admission, fragment) { + t.Fatalf("production local-test trust admission lost %q", fragment) + } + } + + readOnlyStart := strings.Index(windowsSource, + "func proveNativePackageSettledLocalTestTopologyAbsentReadOnly(") + readOnlyEnd := strings.Index(windowsSource, + "func proveNativePackageNormalBrokerJournalsQuiescent(") + if readOnlyStart < 0 || readOnlyEnd <= readOnlyStart { + t.Fatal("settled local-test read-only topology proof is missing or malformed") + } + readOnlyProof := windowsSource[readOnlyStart:readOnlyEnd] + for _, fragment := range []string{ + "requireNativePackageRecoveryServiceAbsent(serviceName)", + "createOrOpenProtectedNativeBrokerJournalDirectory(root, false)", + "len(entries) != 0", + `helperPath, []string{"status"}`, + "validateNativePackageRecoverEmptyStatus(statusOutput, statusExitCode)", + } { + if !strings.Contains(readOnlyProof, fragment) { + t.Fatalf("settled local-test read-only topology proof lost %q", fragment) + } + } + for _, forbidden := range []string{ + `"recover-failed-install-recordless"`, + "reconcileNativeBrokerJournalInactiveDirectories(", + "discardNativeBrokerJournalDirectory(", + "retireNativePackageLocalTestTrustRecord(", + } { + if strings.Contains(readOnlyProof, forbidden) { + t.Fatalf("settled local-test read-only topology proof retained mutator %q", forbidden) + } + } +} + func readNativePackageContractFile(t *testing.T, path string) string { t.Helper() content, err := os.ReadFile(path) diff --git a/internal/cmd/native_package_recover.go b/internal/cmd/native_package_recover.go new file mode 100644 index 00000000..35a8b0e4 --- /dev/null +++ b/internal/cmd/native_package_recover.go @@ -0,0 +1,505 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +var nativePackageRecoverEmptyStatusPattern = regexp.MustCompile( + `(?m)^devices=0 packages=0\r?$`, +) +var nativePackageRecoverStatusOutcomePattern = regexp.MustCompile( + `(?m)^result=success operation=status changed=0 rebootRequired=0 rollback=not-needed exitCode=0\r?$`, +) + +// NativePackageRecover is deliberately narrower than Uninstall. It verifies +// the packaged helper and invokes only the verify-only, recordless R4 failure +// operation. It never invokes generic recover/remove and cannot reconcile or +// mutate an independently installed successor journal or topology. +type NativePackageRecover struct { + DriverHelper string `help:"Path to the packaged ViiperUdeCtl.exe." required:""` + ExpectedHelperSHA256 string `help:"Installer-embedded SHA-256 of ViiperUdeCtl.exe." required:""` + CertificatePath string `help:"Path to the exact failed-install certificate bytes." required:""` + ExpectedCertificateSHA256 string `help:"SHA-256 of the exact failed-install certificate bytes." required:""` + RecoveryAuthorization string `help:"Path to the immutable failed-install recovery authorization receipt." required:""` + ExpectedRecoveryAuthorizationSHA256 string `help:"SHA-256 of the immutable failed-install recovery authorization receipt." required:""` + RecoveryRootAuthorizationSHA256 string `help:"Stable SHA-256 of the first failed-install recovery authorization in this retry chain." required:""` + SourceRevision string `help:"Current source-bound VIIPER revision." required:""` + RecoveryCapability string `help:"Protected parent-bound failed-install recovery capability." required:""` + ExpectedRecoveryCapabilitySHA256 string `help:"SHA-256 of the protected failed-install recovery capability." required:""` + CurrentPackageLockSHA256 string `help:"Current source-bound local-test package-lock SHA-256." required:""` + CurrentBundleManifestSHA256 string `help:"Current source-bound validation bundle-manifest SHA-256." required:""` + AllowPartialCertificateState bool `help:"Allow an exact zero/one per-store state only on a bound retry."` +} + +type nativePackageRecoverRequest struct { + driverHelper string + expectedHelperSHA256 string + certificatePath string + expectedCertificateSHA256 string + recoveryAuthorization string + expectedRecoveryAuthorizationSHA256 string + recoveryRootAuthorizationSHA256 string + sourceRevision string + brokerSource string + recoveryCapability string + expectedRecoveryCapabilitySHA256 string + currentPackageLockSHA256 string + currentBundleManifestSHA256 string + allowPartialCertificateState bool +} + +func (r nativePackageRecoverRequest) validate() error { + if strings.TrimSpace(r.driverHelper) == "" { + return errors.New("native package recovery driver helper is empty") + } + if strings.IndexByte(r.driverHelper, 0) >= 0 { + return errors.New("native package recovery driver helper contains NUL") + } + if !filepath.IsAbs(r.driverHelper) { + return fmt.Errorf("native package recovery driver helper must be an absolute path: %s", r.driverHelper) + } + if !strings.EqualFold(filepath.Base(r.driverHelper), "ViiperUdeCtl.exe") { + return fmt.Errorf("native package recovery helper must be named ViiperUdeCtl.exe: %s", r.driverHelper) + } + if !nativePackageSHA256.MatchString(r.expectedHelperSHA256) { + return errors.New("native package recovery helper SHA-256 must contain exactly 64 hexadecimal characters") + } + if strings.TrimSpace(r.certificatePath) == "" || strings.IndexByte(r.certificatePath, 0) >= 0 || + !filepath.IsAbs(r.certificatePath) || !strings.EqualFold(filepath.Base(r.certificatePath), "ViiperUdeTest.cer") { + return errors.New("native package recovery certificate must be an absolute ViiperUdeTest.cer path") + } + if !nativePackageSHA256.MatchString(r.expectedCertificateSHA256) { + return errors.New("native package recovery certificate SHA-256 must contain exactly 64 hexadecimal characters") + } + if strings.TrimSpace(r.recoveryAuthorization) == "" || + strings.IndexByte(r.recoveryAuthorization, 0) >= 0 || + !filepath.IsAbs(r.recoveryAuthorization) || + !strings.EqualFold(filepath.Base(r.recoveryAuthorization), "failed-install-recovery-progress.json") { + return errors.New("native package recovery authorization must be an absolute failed-install-recovery-progress.json path") + } + if !nativePackageSHA256.MatchString(r.expectedRecoveryAuthorizationSHA256) { + return errors.New("native package recovery authorization SHA-256 must contain exactly 64 hexadecimal characters") + } + if !nativePackageSHA256.MatchString(r.recoveryRootAuthorizationSHA256) { + return errors.New("native package recovery root authorization SHA-256 must contain exactly 64 hexadecimal characters") + } + if !nativePackageHexRevision.MatchString(r.sourceRevision) { + return errors.New("native package recovery source revision must contain exactly 40 or 64 hexadecimal characters") + } + if strings.TrimSpace(r.brokerSource) == "" || strings.IndexByte(r.brokerSource, 0) >= 0 || + !filepath.IsAbs(r.brokerSource) { + return errors.New("native package recovery broker source must be an absolute path without NUL") + } + if strings.TrimSpace(r.recoveryCapability) == "" || + strings.IndexByte(r.recoveryCapability, 0) >= 0 || + !filepath.IsAbs(r.recoveryCapability) || + !strings.EqualFold(filepath.Base(r.recoveryCapability), nativePackageFailedInstallRecoveryCapabilityName) { + return errors.New("native package recovery capability must be an absolute failed-install-recovery-capability.json path") + } + if !nativePackageSHA256.MatchString(r.expectedRecoveryCapabilitySHA256) || + !nativePackageSHA256.MatchString(r.currentPackageLockSHA256) || + !nativePackageSHA256.MatchString(r.currentBundleManifestSHA256) { + return errors.New("native package recovery capability, package-lock, and bundle-manifest SHA-256 values must be exact") + } + return nil +} + +const nativePackageFailedInstallRecoveryCapabilitySchema = "viiper.native.failed-install-recovery-capability/v1" +const nativePackageFailedInstallRecoveryCapabilityName = "failed-install-recovery-capability.json" + +const ( + nativePackageR4RecoveryProgressSchema = "viiper.windows11.failed-install-recovery-progress/v1" + nativePackageR4EvidenceRoot = `C:\Users\hbash\Documents\Codex\2026-08-15\the\outputs\VIIPER-Win11-9481f9d-272f6a0-r4` + nativePackageR4InstallEvidenceDirectory = `C:\Users\hbash\Documents\Codex\2026-08-15\the\outputs\VIIPER-Win11-9481f9d-272f6a0-r4\steps\20260816T034608909Z-install-27fffa05b7e544feb3c5a415ebd1f6c4` + nativePackageR4StatePath = `C:\Users\hbash\Documents\Codex\2026-08-15\the\outputs\VIIPER-Win11-9481f9d-272f6a0-r4\state\validation-state.json` + nativePackageR4StateSHA256 = "e13c686a0cddcf66620940005568b3a7a9a41abb277f61977dd88994863d8cda" + nativePackageR4InstallCommandSHA256 = "c38579b1504c8851dd72317d49f4439d14b7878b4e19907ebe864c8ad986e3f7" + nativePackageR4InstallResultSHA256 = "1095194f448455f746b5af92b89ae4f08f8f69a7ba9fac1d17a90d73e8a971b0" + // The exact stdout digest binds changed=0, rebootRequired=0, + // rollback=not-needed, exitCode=4, phase=install-journal-broker-image-hash, + // win32Error=23, and the immutable-broker-digest failure message. + nativePackageR4InstallStdoutSHA256 = "ca95fac3b8bd6fe7871a7f42400031f01ea946dc88786e9e9a746084144c205b" + nativePackageR4InstallStderrSHA256 = "2610d56f76be3c1aea4f6b3dd4e4b38d134a1d311133ac46f389a28f8faeb520" + nativePackageR4BundleManifestSHA256 = "765de4fe822004e97940fa66ba73602dafd68194d14fd64e20b388444cd4c247" + nativePackageR4ViiperSourceRevision = "9481f9dbfde64af99905fa325546e50b5ea03d6e" + nativePackageR4DS4WindowsSourceRevision = "272f6a05f1476d5aa9c055a234e61c292d3c1556" + nativePackageR4PackageLockSHA256 = "16e08c31bb1c240a3612a6c4ddc8219b040d0e2dec5773e39f363d045113ab8c" + nativePackageR4CertificateSHA256 = "09ca0c2d4d3da29268eff59cf85b6c1347d4a28ddc098b8640381694ad74c517" +) + +var nativePackageR4RecoveryTargetSIDPattern = regexp.MustCompile( + `^S-1-5-21-(?:[0-9]+-){3}[0-9]+$`, +) + +type nativePackageR4RecoveryPredecessor struct { + PredecessorEvidenceRoot string `json:"predecessorEvidenceRoot"` + InstallEvidenceDirectory string `json:"installEvidenceDirectory"` + StatePath string `json:"statePath"` + StateSHA256 string `json:"stateSha256"` + CommandSHA256 string `json:"commandSha256"` + ResultSHA256 string `json:"resultSha256"` + StdoutSHA256 string `json:"stdoutSha256"` + StderrSHA256 string `json:"stderrSha256"` + BundleManifestSHA256 string `json:"bundleManifestSha256"` + ViiperSourceRevision string `json:"viiperSourceRevision"` + DS4WindowsSourceRevision string `json:"ds4WindowsSourceRevision"` + PackageLockSHA256 string `json:"packageLockSha256"` +} + +type nativePackageR4RecoveryTrustBefore struct { + Root int `json:"Root"` + TrustedPublisher int `json:"TrustedPublisher"` +} + +type nativePackageR4RecoveryAuthorization struct { + Schema string `json:"schema"` + Status string `json:"status"` + RetryPermitted bool `json:"retryPermitted"` + FirstAuthorizedUTC string `json:"firstAuthorizedUtc"` + CurrentBundleManifestSHA256 string `json:"currentBundleManifestSha256"` + CurrentViiperSourceRevision string `json:"currentViiperSourceRevision"` + CurrentPackageLockSHA256 string `json:"currentPackageLockSha256"` + Predecessor nativePackageR4RecoveryPredecessor `json:"predecessor"` + PredecessorCertificateSHA256 string `json:"predecessorCertificateSha256"` + Machine string `json:"machine"` + TargetUserSID string `json:"targetUserSid"` + TrustBeforeNativeAttempt nativePackageR4RecoveryTrustBefore `json:"trustBeforeNativeAttempt"` + Resume bool `json:"resume"` + UpdatedUTC string `json:"updatedUtc"` + RecoveryRootAuthorizationSHA256 *string `json:"recoveryRootAuthorizationSha256,omitempty"` +} + +func consumeNativePackageRecoveryJSONValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, ok := token.(json.Delim) + if !ok { + return nil + } + switch delimiter { + case '{': + seen := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return errors.New("recovery authorization object key is not a string") + } + if _, exists := seen[key]; exists { + return fmt.Errorf("recovery authorization contains duplicate JSON field %q", key) + } + seen[key] = struct{}{} + if err := consumeNativePackageRecoveryJSONValue(decoder); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil { + return err + } + if closing != json.Delim('}') { + return errors.New("recovery authorization object is not terminated") + } + case '[': + for decoder.More() { + if err := consumeNativePackageRecoveryJSONValue(decoder); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil { + return err + } + if closing != json.Delim(']') { + return errors.New("recovery authorization array is not terminated") + } + default: + return errors.New("recovery authorization contains an unexpected JSON delimiter") + } + return nil +} + +func rejectNativePackageRecoveryDuplicateJSONFields(contents []byte) error { + decoder := json.NewDecoder(bytes.NewReader(contents)) + decoder.UseNumber() + if err := consumeNativePackageRecoveryJSONValue(decoder); err != nil { + return err + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("recovery authorization contains trailing JSON") + } + return fmt.Errorf("read recovery authorization terminator: %w", err) + } + return nil +} + +func requireNativePackageRecoveryJSONKeys( + object map[string]json.RawMessage, + required []string, + optional []string, + label string, +) error { + allowed := make(map[string]bool, len(required)+len(optional)) + for _, name := range required { + allowed[name] = true + if _, present := object[name]; !present { + return fmt.Errorf("recovery authorization %s is missing field %q", label, name) + } + } + for _, name := range optional { + allowed[name] = true + } + for name := range object { + if !allowed[name] { + return fmt.Errorf("recovery authorization %s contains unknown field %q", label, name) + } + } + return nil +} + +func decodeNativePackageR4RecoveryAuthorization( + contents []byte, +) (nativePackageR4RecoveryAuthorization, error) { + value := nativePackageR4RecoveryAuthorization{} + if err := rejectNativePackageRecoveryDuplicateJSONFields(contents); err != nil { + return value, err + } + root := make(map[string]json.RawMessage) + if err := json.Unmarshal(contents, &root); err != nil { + return value, fmt.Errorf("parse recovery authorization object: %w", err) + } + if err := requireNativePackageRecoveryJSONKeys(root, []string{ + "schema", "status", "retryPermitted", "firstAuthorizedUtc", + "currentBundleManifestSha256", "currentViiperSourceRevision", + "currentPackageLockSha256", "predecessor", + "predecessorCertificateSha256", "machine", "targetUserSid", + "trustBeforeNativeAttempt", "resume", "updatedUtc", + }, []string{"recoveryRootAuthorizationSha256"}, "root"); err != nil { + return value, err + } + predecessor := make(map[string]json.RawMessage) + if err := json.Unmarshal(root["predecessor"], &predecessor); err != nil { + return value, fmt.Errorf("parse recovery authorization predecessor: %w", err) + } + if err := requireNativePackageRecoveryJSONKeys(predecessor, []string{ + "predecessorEvidenceRoot", "installEvidenceDirectory", "statePath", + "stateSha256", "commandSha256", "resultSha256", "stdoutSha256", + "stderrSha256", "bundleManifestSha256", "viiperSourceRevision", + "ds4WindowsSourceRevision", "packageLockSha256", + }, nil, "predecessor"); err != nil { + return value, err + } + trust := make(map[string]json.RawMessage) + if err := json.Unmarshal(root["trustBeforeNativeAttempt"], &trust); err != nil { + return value, fmt.Errorf("parse recovery authorization trust admission: %w", err) + } + if err := requireNativePackageRecoveryJSONKeys( + trust, []string{"Root", "TrustedPublisher"}, nil, "trust admission", + ); err != nil { + return value, err + } + decoder := json.NewDecoder(bytes.NewReader(contents)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, fmt.Errorf("decode exact recovery authorization: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return value, errors.New("recovery authorization contains trailing JSON") + } + return value, fmt.Errorf("read exact recovery authorization terminator: %w", err) + } + return value, nil +} + +func validateNativePackageR4RecoveryAuthorization( + contents []byte, + request nativePackageRecoverRequest, + currentHostname string, +) (nativePackageR4RecoveryAuthorization, error) { + authorization, err := decodeNativePackageR4RecoveryAuthorization(contents) + if err != nil { + return authorization, err + } + predecessor := authorization.Predecessor + if authorization.Schema != nativePackageR4RecoveryProgressSchema || + authorization.Status != "native-attempt" || !authorization.RetryPermitted || + authorization.CurrentBundleManifestSHA256 != request.currentBundleManifestSHA256 || + authorization.CurrentViiperSourceRevision != request.sourceRevision || + authorization.CurrentPackageLockSHA256 != request.currentPackageLockSHA256 || + authorization.PredecessorCertificateSHA256 != nativePackageR4CertificateSHA256 || + authorization.PredecessorCertificateSHA256 != request.expectedCertificateSHA256 || + predecessor.StateSHA256 != nativePackageR4StateSHA256 || + predecessor.CommandSHA256 != nativePackageR4InstallCommandSHA256 || + predecessor.ResultSHA256 != nativePackageR4InstallResultSHA256 || + predecessor.StdoutSHA256 != nativePackageR4InstallStdoutSHA256 || + predecessor.StderrSHA256 != nativePackageR4InstallStderrSHA256 || + predecessor.BundleManifestSHA256 != nativePackageR4BundleManifestSHA256 || + predecessor.ViiperSourceRevision != nativePackageR4ViiperSourceRevision || + predecessor.DS4WindowsSourceRevision != nativePackageR4DS4WindowsSourceRevision || + predecessor.PackageLockSHA256 != nativePackageR4PackageLockSHA256 || + !strings.EqualFold(predecessor.PredecessorEvidenceRoot, nativePackageR4EvidenceRoot) || + !strings.EqualFold(predecessor.InstallEvidenceDirectory, nativePackageR4InstallEvidenceDirectory) || + !strings.EqualFold(predecessor.StatePath, nativePackageR4StatePath) { + return authorization, errors.New("recovery authorization does not bind the exact manifest-known R4 failed-install predecessor and failure proof") + } + if strings.TrimSpace(authorization.Machine) == "" || + !strings.EqualFold(authorization.Machine, currentHostname) || + !nativePackageR4RecoveryTargetSIDPattern.MatchString(authorization.TargetUserSID) { + return authorization, errors.New("recovery authorization machine and target user identity are invalid") + } + if _, err := time.Parse(time.RFC3339Nano, authorization.FirstAuthorizedUTC); err != nil { + return authorization, errors.New("recovery authorization first authorization time is invalid") + } + if _, err := time.Parse(time.RFC3339Nano, authorization.UpdatedUTC); err != nil { + return authorization, errors.New("recovery authorization update time is invalid") + } + if authorization.Resume != request.allowPartialCertificateState { + return authorization, errors.New("recovery authorization resume state does not match the native request") + } + trust := authorization.TrustBeforeNativeAttempt + if !authorization.Resume { + if authorization.RecoveryRootAuthorizationSHA256 != nil || + trust.Root != 1 || trust.TrustedPublisher != 1 { + return authorization, errors.New("initial recovery authorization has invalid root binding or trust admission") + } + return authorization, nil + } + if authorization.RecoveryRootAuthorizationSHA256 == nil || + *authorization.RecoveryRootAuthorizationSHA256 != request.recoveryRootAuthorizationSHA256 || + trust.Root < 0 || trust.Root > 1 || trust.TrustedPublisher < 0 || trust.TrustedPublisher > 1 { + return authorization, errors.New("recovery retry authorization has invalid root binding or trust admission") + } + return authorization, nil +} + +type nativePackageFailedInstallRecoveryCapability struct { + Schema string `json:"schema"` + Nonce string `json:"nonce"` + ParentPID uint32 `json:"parentPid"` + ParentCreationFileTime uint64 `json:"parentCreationFileTime"` + LeasePath string `json:"leasePath"` + SourceRevision string `json:"sourceRevision"` + HelperSHA256 string `json:"helperSha256"` + CertificateSHA256 string `json:"certificateSha256"` + RecoveryAuthorizationSHA256 string `json:"recoveryAuthorizationSha256"` + RecoveryRootAuthorizationSHA256 string `json:"recoveryRootAuthorizationSha256"` + PackageLockSHA256 string `json:"packageLockSha256"` + BundleManifestSHA256 string `json:"bundleManifestSha256"` + AllowPartialCertificateState bool `json:"allowPartialCertificateState"` +} + +type nativePackageRecoverProof struct { + success bool + changed bool + rebootRequired bool + rollback string + exitCode int +} + +func parseNativePackageRecoverProof(output string, processExitCode int) (nativePackageRecoverProof, error) { + if len(output) == 0 || len(output) > nativePackageRemoveProofMaximumLineBytes { + return nativePackageRecoverProof{}, errors.New("recordless recovery helper output is empty or exceeds its bound") + } + const canonical = "result=success operation=recover-failed-install-recordless changed=0 rebootRequired=0 rollback=not-needed exitCode=0" + if processExitCode != 0 { + return nativePackageRecoverProof{}, fmt.Errorf( + "recordless recovery helper process exited %d", processExitCode, + ) + } + if output != canonical+"\n" && output != canonical+"\r\n" { + return nativePackageRecoverProof{}, errors.New( + "recordless recovery helper did not emit exactly one canonical terminated success line", + ) + } + return nativePackageRecoverProof{ + success: true, rollback: "not-needed", exitCode: 0, + }, nil +} + +func validateNativePackageRecoverEmptyStatus(output string, processExitCode int) error { + if processExitCode != 0 { + return fmt.Errorf("driver helper status process exited %d", processExitCode) + } + if len(output) > 2*nativePackageRemoveProofMaximumLineBytes { + return errors.New("driver helper status evidence exceeded its bounded contract") + } + if len(nativePackageRecoverEmptyStatusPattern.FindAllStringIndex(output, -1)) != 1 || + len(nativePackageRecoverStatusOutcomePattern.FindAllStringIndex(output, -1)) != 1 { + return errors.New("driver helper did not prove exact zero-device, zero-package recovery status") + } + resultLines := 0 + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSuffix(line, "\r") + if strings.HasPrefix(line, "result=") { + resultLines++ + } + } + if resultLines != 1 { + return errors.New("driver helper status did not emit exactly one structured outcome") + } + return nil +} + +type nativePackageRecoverExitError struct { + cause error + exitCode int +} + +func (e *nativePackageRecoverExitError) Error() string { return e.cause.Error() } +func (e *nativePackageRecoverExitError) Unwrap() error { return e.cause } +func (e *nativePackageRecoverExitError) ExitCode() int { return e.exitCode } + +func (c *NativePackageRecover) Run(logger *slog.Logger) error { + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("locate native package recovery broker: %w", err) + } + request := nativePackageRecoverRequest{ + driverHelper: strings.TrimSpace(c.DriverHelper), + expectedHelperSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedHelperSHA256)), + certificatePath: strings.TrimSpace(c.CertificatePath), + expectedCertificateSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedCertificateSHA256)), + recoveryAuthorization: strings.TrimSpace(c.RecoveryAuthorization), + expectedRecoveryAuthorizationSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedRecoveryAuthorizationSHA256)), + recoveryRootAuthorizationSHA256: strings.ToLower(strings.TrimSpace(c.RecoveryRootAuthorizationSHA256)), + sourceRevision: strings.ToLower(strings.TrimSpace(c.SourceRevision)), + brokerSource: executable, + recoveryCapability: strings.TrimSpace(c.RecoveryCapability), + expectedRecoveryCapabilitySHA256: strings.ToLower(strings.TrimSpace(c.ExpectedRecoveryCapabilitySHA256)), + currentPackageLockSHA256: strings.ToLower(strings.TrimSpace(c.CurrentPackageLockSHA256)), + currentBundleManifestSHA256: strings.ToLower(strings.TrimSpace(c.CurrentBundleManifestSHA256)), + allowPartialCertificateState: c.AllowPartialCertificateState, + } + if err := request.validate(); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), nativePackageTransactionTimeout) + defer cancel() + return recoverNativePackage(ctx, logger, request) +} + +func nativePackageRecoverDeadline(ctx context.Context) (time.Time, error) { + deadline, ok := ctx.Deadline() + if !ok || !deadline.After(time.Now()) { + return time.Time{}, context.DeadlineExceeded + } + return deadline, nil +} diff --git a/internal/cmd/native_package_recover_nonwindows.go b/internal/cmd/native_package_recover_nonwindows.go new file mode 100644 index 00000000..5045da88 --- /dev/null +++ b/internal/cmd/native_package_recover_nonwindows.go @@ -0,0 +1,17 @@ +//go:build !windows + +package cmd + +import ( + "context" + "errors" + "log/slog" +) + +func recoverNativePackage( + context.Context, + *slog.Logger, + nativePackageRecoverRequest, +) error { + return errors.New("native package journal recovery is available only on Windows") +} diff --git a/internal/cmd/native_package_recover_test.go b/internal/cmd/native_package_recover_test.go new file mode 100644 index 00000000..beba2d8e --- /dev/null +++ b/internal/cmd/native_package_recover_test.go @@ -0,0 +1,331 @@ +package cmd + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" +) + +func validNativePackageRecoverRequest() nativePackageRecoverRequest { + return nativePackageRecoverRequest{ + driverHelper: `C:\bundle\ViiperUdeCtl.exe`, + expectedHelperSHA256: strings.Repeat("a", 64), + certificatePath: `C:\bundle\ViiperUdeTest.cer`, + expectedCertificateSHA256: strings.Repeat("b", 64), + recoveryAuthorization: `C:\evidence\failed-install-recovery-progress.json`, + expectedRecoveryAuthorizationSHA256: strings.Repeat("c", 64), + recoveryRootAuthorizationSHA256: strings.Repeat("d", 64), + sourceRevision: strings.Repeat("e", 40), + brokerSource: `C:\stage\viiper.exe`, + recoveryCapability: `C:\stage\failed-install-recovery-capability.json`, + expectedRecoveryCapabilitySHA256: strings.Repeat("f", 64), + currentPackageLockSHA256: strings.Repeat("1", 64), + currentBundleManifestSHA256: strings.Repeat("2", 64), + } +} + +func TestNativePackageRecoverRequestValidation(t *testing.T) { + t.Parallel() + valid := validNativePackageRecoverRequest() + if err := valid.validate(); err != nil { + t.Fatalf("valid request rejected: %v", err) + } + tests := []struct { + name string + mutate func(*nativePackageRecoverRequest) + want string + }{ + {"relative helper", func(r *nativePackageRecoverRequest) { r.driverHelper = "ViiperUdeCtl.exe" }, "absolute"}, + {"wrong helper", func(r *nativePackageRecoverRequest) { r.driverHelper = `C:\bundle\other.exe` }, "named"}, + {"bad helper hash", func(r *nativePackageRecoverRequest) { r.expectedHelperSHA256 = "abc" }, "64"}, + {"relative certificate", func(r *nativePackageRecoverRequest) { r.certificatePath = "ViiperUdeTest.cer" }, "absolute"}, + {"wrong certificate", func(r *nativePackageRecoverRequest) { r.certificatePath = `C:\bundle\other.cer` }, "ViiperUdeTest.cer"}, + {"bad certificate hash", func(r *nativePackageRecoverRequest) { r.expectedCertificateSHA256 = "abc" }, "64"}, + {"relative authorization", func(r *nativePackageRecoverRequest) { + r.recoveryAuthorization = "failed-install-recovery-progress.json" + }, "absolute"}, + {"wrong authorization", func(r *nativePackageRecoverRequest) { r.recoveryAuthorization = `C:\evidence\other.json` }, "failed-install-recovery-progress.json"}, + {"bad authorization hash", func(r *nativePackageRecoverRequest) { r.expectedRecoveryAuthorizationSHA256 = "abc" }, "64"}, + {"bad root authorization hash", func(r *nativePackageRecoverRequest) { r.recoveryRootAuthorizationSHA256 = "abc" }, "root authorization"}, + {"bad source revision", func(r *nativePackageRecoverRequest) { r.sourceRevision = "abc" }, "source revision"}, + {"relative broker", func(r *nativePackageRecoverRequest) { r.brokerSource = "viiper.exe" }, "broker source"}, + {"relative capability", func(r *nativePackageRecoverRequest) { r.recoveryCapability = "failed-install-recovery-capability.json" }, "absolute"}, + {"wrong capability name", func(r *nativePackageRecoverRequest) { r.recoveryCapability = `C:\stage\other.json` }, "failed-install-recovery-capability.json"}, + {"bad capability hash", func(r *nativePackageRecoverRequest) { r.expectedRecoveryCapabilitySHA256 = "abc" }, "capability"}, + {"bad package lock hash", func(r *nativePackageRecoverRequest) { r.currentPackageLockSHA256 = "abc" }, "package-lock"}, + {"bad bundle manifest hash", func(r *nativePackageRecoverRequest) { r.currentBundleManifestSHA256 = "abc" }, "bundle-manifest"}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + request := valid + test.mutate(&request) + err := request.validate() + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error=%v want substring %q", err, test.want) + } + }) + } +} + +func validNativePackageR4RecoveryAuthorization( + request nativePackageRecoverRequest, + machine string, +) nativePackageR4RecoveryAuthorization { + return nativePackageR4RecoveryAuthorization{ + Schema: nativePackageR4RecoveryProgressSchema, + Status: "native-attempt", + RetryPermitted: true, + FirstAuthorizedUTC: time.Now().UTC().Format(time.RFC3339Nano), + CurrentBundleManifestSHA256: request.currentBundleManifestSHA256, + CurrentViiperSourceRevision: request.sourceRevision, + CurrentPackageLockSHA256: request.currentPackageLockSHA256, + Predecessor: nativePackageR4RecoveryPredecessor{ + PredecessorEvidenceRoot: nativePackageR4EvidenceRoot, + InstallEvidenceDirectory: nativePackageR4InstallEvidenceDirectory, + StatePath: nativePackageR4StatePath, + StateSHA256: nativePackageR4StateSHA256, + CommandSHA256: nativePackageR4InstallCommandSHA256, + ResultSHA256: nativePackageR4InstallResultSHA256, + StdoutSHA256: nativePackageR4InstallStdoutSHA256, + StderrSHA256: nativePackageR4InstallStderrSHA256, + BundleManifestSHA256: nativePackageR4BundleManifestSHA256, + ViiperSourceRevision: nativePackageR4ViiperSourceRevision, + DS4WindowsSourceRevision: nativePackageR4DS4WindowsSourceRevision, + PackageLockSHA256: nativePackageR4PackageLockSHA256, + }, + PredecessorCertificateSHA256: nativePackageR4CertificateSHA256, + Machine: machine, + TargetUserSID: "S-1-5-21-1-2-3-1001", + TrustBeforeNativeAttempt: nativePackageR4RecoveryTrustBefore{ + Root: 1, TrustedPublisher: 1, + }, + UpdatedUTC: time.Now().UTC().Format(time.RFC3339Nano), + } +} + +func marshalNativePackageRecoveryAuthorization( + t *testing.T, + value nativePackageR4RecoveryAuthorization, +) []byte { + t.Helper() + contents, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return contents +} + +func TestNativePackageR4RecoveryAuthorizationIsExactAndSourceBound(t *testing.T) { + t.Parallel() + const machine = "VIIPER-R4-CONTRACT" + request := validNativePackageRecoverRequest() + request.expectedCertificateSHA256 = nativePackageR4CertificateSHA256 + valid := validNativePackageR4RecoveryAuthorization(request, machine) + if _, err := validateNativePackageR4RecoveryAuthorization( + marshalNativePackageRecoveryAuthorization(t, valid), request, machine, + ); err != nil { + t.Fatalf("exact R4 recovery authorization rejected: %v", err) + } + resumeRequest := request + resumeRequest.allowPartialCertificateState = true + resume := valid + resume.Resume = true + resume.TrustBeforeNativeAttempt = nativePackageR4RecoveryTrustBefore{ + Root: 0, TrustedPublisher: 1, + } + resume.RecoveryRootAuthorizationSHA256 = &resumeRequest.recoveryRootAuthorizationSHA256 + if _, err := validateNativePackageR4RecoveryAuthorization( + marshalNativePackageRecoveryAuthorization(t, resume), resumeRequest, machine, + ); err != nil { + t.Fatalf("exact bound R4 recovery retry authorization rejected: %v", err) + } + + tests := []struct { + name string + contents func() []byte + hostname string + want string + }{ + { + name: "fabricated predecessor", + contents: func() []byte { + value := valid + value.Predecessor.StateSHA256 = strings.Repeat("f", 64) + return marshalNativePackageRecoveryAuthorization(t, value) + }, + hostname: machine, + want: "exact manifest-known R4", + }, + { + name: "missing field", + contents: func() []byte { + value := make(map[string]any) + if err := json.Unmarshal(marshalNativePackageRecoveryAuthorization(t, valid), &value); err != nil { + t.Fatal(err) + } + delete(value, "status") + contents, _ := json.Marshal(value) + return contents + }, + hostname: machine, + want: "missing field", + }, + { + name: "unknown field", + contents: func() []byte { + value := make(map[string]any) + if err := json.Unmarshal(marshalNativePackageRecoveryAuthorization(t, valid), &value); err != nil { + t.Fatal(err) + } + value["unboundAuthority"] = strings.Repeat("9", 64) + contents, _ := json.Marshal(value) + return contents + }, + hostname: machine, + want: "unknown field", + }, + { + name: "duplicate field", + contents: func() []byte { + contents := string(marshalNativePackageRecoveryAuthorization(t, valid)) + return []byte(strings.Replace( + contents, + `"status":"native-attempt"`, + `"status":"native-attempt","status":"native-attempt"`, + 1, + )) + }, + hostname: machine, + want: "duplicate JSON field", + }, + { + name: "other machine", + contents: func() []byte { return marshalNativePackageRecoveryAuthorization(t, valid) }, + hostname: "OTHER-MACHINE", + want: "machine and target user", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + _, err := validateNativePackageR4RecoveryAuthorization( + test.contents(), request, test.hostname, + ) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error=%v want substring %q", err, test.want) + } + }) + } +} + +func TestNativePackageRecoverStructuredProof(t *testing.T) { + t.Parallel() + canonical := "result=success operation=recover-failed-install-recordless changed=0 rebootRequired=0 rollback=not-needed exitCode=0" + tests := []struct { + name string + output string + exit int + wantErr bool + errContains string + }{ + { + name: "canonical LF success", + output: canonical + "\n", + }, + { + name: "canonical CRLF success", + output: canonical + "\r\n", + }, + { + name: "missing terminator", + output: canonical, + wantErr: true, + errContains: "canonical terminated", + }, + { + name: "generic recover forbidden", + output: "result=success operation=recover changed=0 rebootRequired=0 rollback=not-needed exitCode=0\n", + wantErr: true, + errContains: "canonical terminated", + }, + { + name: "changed recovery forbidden", + output: "result=success operation=recover-failed-install-recordless changed=1 rebootRequired=0 rollback=not-needed exitCode=0\n", + wantErr: true, + errContains: "canonical terminated", + }, + { + name: "journal binding forbidden", + output: canonical + "\njournal-binding operation=install transactionId=unsafe\n", + wantErr: true, + errContains: "exactly one", + }, + { + name: "recovery diagnostics forbidden", + output: canonical + ` recoveryRecord="C:\\ProgramData\\VIIPER\\UdeCx\\active-v2"` + "\n", + wantErr: true, + errContains: "canonical terminated", + }, + { + name: "extra blank line forbidden", + output: canonical + "\n\n", + wantErr: true, + errContains: "exactly one", + }, + { + name: "nonzero process exit forbidden", + output: canonical + "\n", + exit: 4, + wantErr: true, + errContains: "exited 4", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + _, err := parseNativePackageRecoverProof(test.output, test.exit) + if (err != nil) != test.wantErr { + t.Fatalf("error=%v wantErr=%v", err, test.wantErr) + } + if test.errContains != "" && (err == nil || !strings.Contains(err.Error(), test.errContains)) { + t.Fatalf("error=%v missing %q", err, test.errContains) + } + }) + } +} + +func TestNativePackageRecoverStatusRequiresEmptyTopology(t *testing.T) { + t.Parallel() + valid := "devices=0 packages=0\nresult=success operation=status changed=0 rebootRequired=0 rollback=not-needed exitCode=0\n" + if err := validateNativePackageRecoverEmptyStatus(valid, 0); err != nil { + t.Fatalf("empty status rejected: %v", err) + } + for name, output := range map[string]string{ + "successor device": "devices=1 packages=1\nresult=success operation=status changed=0 rebootRequired=0 rollback=not-needed exitCode=0\n", + "successor package": "devices=0 packages=1\nresult=success operation=status changed=0 rebootRequired=0 rollback=not-needed exitCode=0\n", + "duplicate outcome": valid + "result=success operation=status changed=0 rebootRequired=0 rollback=not-needed exitCode=0\n", + } { + output := output + t.Run(name, func(t *testing.T) { + t.Parallel() + if err := validateNativePackageRecoverEmptyStatus(output, 0); err == nil { + t.Fatal("unsafe successor status was admitted") + } + }) + } +} + +func TestNativePackageRecoverExitErrorPreservesCode(t *testing.T) { + t.Parallel() + err := &nativePackageRecoverExitError{cause: errors.New("rejected"), exitCode: 4} + var exitCoder interface{ ExitCode() int } + if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != 4 { + t.Fatalf("exit error lost code: %v", err) + } +} diff --git a/internal/cmd/native_package_recover_windows.go b/internal/cmd/native_package_recover_windows.go new file mode 100644 index 00000000..ac638270 --- /dev/null +++ b/internal/cmd/native_package_recover_windows.go @@ -0,0 +1,1482 @@ +//go:build windows + +package cmd + +import ( + "bytes" + "context" + cryptorand "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc/mgr" +) + +const nativePackageRecoveryDriverServiceName = "ViiperUde" + +const ( + nativePackageRecoveryMaximumCertificateBytes = 1024 * 1024 + nativePackageRecoveryMaximumAuthorizationBytes = 256 * 1024 + nativePackageRecoveryMaximumEvidenceBytes = 16 * 1024 * 1024 + nativePackageRecoveryTrustLeaseDirectoryName = "VIIPER-TrustManager" + nativePackageRecoveryTrustLeaseFileName = "lease-v1.lock" + nativePackageRecoveryTrustLeaseDirectorySDDL = "O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + nativePackageRecoveryTrustLeaseFileSDDL = "O:BAG:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)" + nativePackageRecoveryMarkerPreparingName = "failed-install-recovery-preparing-v1.json" + nativePackageRecoveryMarkerPendingName = "failed-install-recovery-pending-v1.json" + nativePackageRecoveryMarkerSettledName = "failed-install-recovery-settled-v1.json" + nativePackageRecoveryMaximumMarkerBytes = 4096 + nativePackageLocalTrustPreparingName = "local-test-trust-preparing-v1.json" + nativePackageLocalTrustPendingName = "local-test-trust-pending-v1.json" + nativePackageLocalTrustOwnedName = "local-test-trust-owned-v1.json" + nativePackageLocalTrustUninstallingName = "local-test-trust-uninstalling-v1.json" +) + +type nativePackageR4RecoveryStateIdentity struct { + Schema string `json:"schema"` + Machine string `json:"machine"` + TargetUserSID string `json:"targetUserSid"` +} + +type nativePackageRecoveryTrustCounts struct { + root int + trustedPublisher int +} + +type nativePackageRecoveryMarkerPaths struct { + directory string + lease string + preparing string + pending string + settled string +} + +type nativePackageRecoveryMarkerState struct { + paths nativePackageRecoveryMarkerPaths + bytes []byte + sha256 string + wasSettled bool + wasResuming bool +} + +type nativePackageRecoveryMarkerRecord struct { + Schema string `json:"schema"` + RootAuthorizationSHA256 string `json:"rootAuthorizationSha256"` + CertificateSHA256 string `json:"certificateSha256"` + SourceRevision string `json:"sourceRevision"` +} + +func validateNativePackageRecoveryMarkerAdmission( + present int, + allowPartial bool, + trustBefore nativePackageRecoveryTrustCounts, +) error { + if present > 1 { + return errors.New("multiple failed-install recovery marker states exist") + } + if present == 0 && allowPartial && + (trustBefore.root != 1 || trustBefore.trustedPublisher != 1) { + return errors.New( + "partial trust on retry requires an exact pre-existing protected recovery marker", + ) + } + if present == 1 && !allowPartial { + return errors.New("an existing recovery marker requires a bound retry authorization") + } + return nil +} + +func resolveNativePackageRecoveryMarkerPaths() (nativePackageRecoveryMarkerPaths, error) { + programData, err := windows.KnownFolderPath( + windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT, + ) + if err != nil { + return nativePackageRecoveryMarkerPaths{}, fmt.Errorf( + "resolve ProgramData trust-marker root: %w", err, + ) + } + programData = filepath.Clean(programData) + directory := filepath.Join(programData, nativePackageRecoveryTrustLeaseDirectoryName) + paths := nativePackageRecoveryMarkerPaths{ + directory: directory, + lease: filepath.Join(directory, nativePackageRecoveryTrustLeaseFileName), + preparing: filepath.Join(directory, nativePackageRecoveryMarkerPreparingName), + pending: filepath.Join(directory, nativePackageRecoveryMarkerPendingName), + settled: filepath.Join(directory, nativePackageRecoveryMarkerSettledName), + } + if !strings.EqualFold(filepath.Dir(directory), programData) || + !strings.EqualFold(filepath.Dir(paths.lease), directory) || + !strings.EqualFold(filepath.Dir(paths.preparing), directory) || + !strings.EqualFold(filepath.Dir(paths.pending), directory) || + !strings.EqualFold(filepath.Dir(paths.settled), directory) { + return nativePackageRecoveryMarkerPaths{}, errors.New( + "native package recovery marker escaped fixed ProgramData", + ) + } + return paths, nil +} + +func canonicalNativePackageRecoveryMarker( + request nativePackageRecoverRequest, +) ([]byte, string) { + contents := []byte(fmt.Sprintf( + "{\"schema\":\"viiper.native.failed-install-recovery-marker/v1\",\"rootAuthorizationSha256\":\"%s\",\"certificateSha256\":\"%s\",\"sourceRevision\":\"%s\"}\n", + request.recoveryRootAuthorizationSHA256, + request.expectedCertificateSHA256, + request.sourceRevision, + )) + digest := sha256.Sum256(contents) + return contents, hex.EncodeToString(digest[:]) +} + +func readExactNativePackageRecoveryMarker(path string, expected []byte) (bool, error) { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return false, err + } + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_READ|windows.READ_CONTROL, + windows.FILE_SHARE_READ, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || + errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return false, nil + } + return false, err + } + defer windows.CloseHandle(handle) //nolint:errcheck + information := windows.ByHandleFileInformation{} + if err := windows.GetFileInformationByHandle(handle, &information); err != nil { + return false, err + } + if information.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + return false, errors.New("recovery marker is not a regular non-reparse file") + } + if err := validateNativeFileLinkCount(information.NumberOfLinks); err != nil { + return false, fmt.Errorf("validate recovery marker link count: %w", err) + } + if err := validateNativeSecurityDescriptor( + handle, nativePackageRecoveryTrustLeaseFileSDDL, + ); err != nil { + return false, fmt.Errorf("validate recovery marker security: %w", err) + } + contents, err := readNativePackageRecoveryFile( + handle, nativePackageRecoveryMaximumMarkerBytes, + ) + if err != nil { + return false, err + } + if !bytes.Equal(contents, expected) { + return false, errors.New("recovery marker bytes do not match this exact authorization, certificate, and source") + } + return true, nil +} + +func decodeCanonicalNativePackageRecoveryMarker( + contents []byte, +) (nativePackageRecoveryMarkerRecord, error) { + value := nativePackageRecoveryMarkerRecord{} + if len(contents) < 2 || len(contents) > nativePackageRecoveryMaximumMarkerBytes || + contents[len(contents)-1] != '\n' || bytes.IndexByte(contents[:len(contents)-1], '\n') >= 0 { + return value, errors.New("settled recovery marker has invalid framing") + } + decoder := json.NewDecoder(bytes.NewReader(contents[:len(contents)-1])) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, fmt.Errorf("decode settled recovery marker: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return value, errors.New("settled recovery marker has trailing JSON") + } + canonical, err := json.Marshal(value) + if err != nil { + return value, err + } + canonical = append(canonical, '\n') + if !bytes.Equal(canonical, contents) { + return value, errors.New("settled recovery marker is not canonical") + } + if value.Schema != "viiper.native.failed-install-recovery-marker/v1" || + !nativePackageSHA256.MatchString(value.RootAuthorizationSHA256) || + !nativePackageSHA256.MatchString(value.CertificateSHA256) || + !nativePackageHexRevision.MatchString(value.SourceRevision) { + return value, errors.New("settled recovery marker identity is invalid") + } + return value, nil +} + +func retireNativePackageSettledRecoveryMarker(path string) error { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return err + } + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_READ|windows.READ_CONTROL|windows.DELETE, + windows.FILE_SHARE_READ, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || + errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil + } + return err + } + closed := false + defer func() { + if !closed { + windows.CloseHandle(handle) //nolint:errcheck + } + }() + information := windows.ByHandleFileInformation{} + if err := windows.GetFileInformationByHandle(handle, &information); err != nil { + return err + } + if information.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + return errors.New("settled recovery marker is not a regular non-reparse file") + } + if err := validateNativeFileLinkCount(information.NumberOfLinks); err != nil { + return fmt.Errorf("validate settled recovery marker link count: %w", err) + } + if err := validateNativeSecurityDescriptor( + handle, nativePackageRecoveryTrustLeaseFileSDDL, + ); err != nil { + return fmt.Errorf("validate settled recovery marker security: %w", err) + } + contents, err := readNativePackageRecoveryFile( + handle, nativePackageRecoveryMaximumMarkerBytes, + ) + if err != nil { + return err + } + if _, err := decodeCanonicalNativePackageRecoveryMarker(contents); err != nil { + return err + } + if err := deleteNativePackageUninstallFileHandle(handle); err != nil { + return fmt.Errorf("delete validated settled recovery marker by handle: %w", err) + } + if err := windows.CloseHandle(handle); err != nil { + return fmt.Errorf("close retired settled recovery marker: %w", err) + } + closed = true + if _, err := nativePathAttributes(path); err == nil { + return errors.New("validated settled recovery marker remained after retirement") + } else if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return fmt.Errorf("prove settled recovery marker retired: %w", err) + } + return nil +} + +func createExactNativePackageRecoveryPreparation(path string, contents []byte) error { + return createExactNativePackageRecoveryPreparationWithCutpoint(path, contents, nil) +} + +func createExactNativePackageRecoveryPreparationWithCutpoint( + path string, + contents []byte, + cutpoint func(string) error, +) error { + if len(contents) == 0 || len(contents) > nativePackageRecoveryMaximumMarkerBytes { + return errors.New("recovery marker preparation length is outside its exact bound") + } + security, err := nativeSecurityAttributes(nativePackageRecoveryTrustLeaseFileSDDL) + if err != nil { + return err + } + path = filepath.Clean(path) + directory := filepath.Dir(path) + var nonce [16]byte + if _, err := cryptorand.Read(nonce[:]); err != nil { + return fmt.Errorf("generate recovery marker scratch identity: %w", err) + } + scratch := filepath.Join( + directory, + "."+filepath.Base(path)+"."+hex.EncodeToString(nonce[:])+".scratch", + ) + if !strings.EqualFold(filepath.Dir(scratch), directory) || + strings.EqualFold(scratch, path) { + return errors.New("recovery marker scratch escaped its exact directory") + } + pointer, err := windows.UTF16PtrFromString(scratch) + if err != nil { + return err + } + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_READ|windows.GENERIC_WRITE|windows.READ_CONTROL, + windows.FILE_SHARE_READ, + security, + windows.CREATE_NEW, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_WRITE_THROUGH| + windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return err + } + closed := false + published := false + defer func() { + if !closed { + windows.CloseHandle(handle) //nolint:errcheck + } + if !published { + deleteNativePackageFile(scratch) //nolint:errcheck + } + }() + if cutpoint != nil { + if err := cutpoint("scratch-created"); err != nil { + return err + } + } + information := windows.ByHandleFileInformation{} + if err := windows.GetFileInformationByHandle(handle, &information); err != nil { + return err + } + if information.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 || + information.NumberOfLinks != 1 { + return errors.New("new recovery marker preparation is not an exact regular single-link file") + } + if err := validateNativeSecurityDescriptor( + handle, nativePackageRecoveryTrustLeaseFileSDDL, + ); err != nil { + return fmt.Errorf("validate new recovery marker security: %w", err) + } + remaining := contents + for len(remaining) != 0 { + var written uint32 + if err := windows.WriteFile(handle, remaining, &written, nil); err != nil { + return err + } + if written == 0 || int(written) > len(remaining) { + return errors.New("recovery marker write made no bounded progress") + } + remaining = remaining[written:] + } + if cutpoint != nil { + if err := cutpoint("scratch-written"); err != nil { + return err + } + } + if err := windows.FlushFileBuffers(handle); err != nil { + return fmt.Errorf("flush recovery marker preparation: %w", err) + } + if cutpoint != nil { + if err := cutpoint("scratch-flushed"); err != nil { + return err + } + } + if _, err := windows.SetFilePointer(handle, 0, nil, windows.FILE_BEGIN); err != nil { + return err + } + readback, err := readNativePackageRecoveryFile( + handle, nativePackageRecoveryMaximumMarkerBytes, + ) + if err != nil { + return err + } + if !bytes.Equal(readback, contents) { + return errors.New("recovery marker preparation changed during write-through readback") + } + if cutpoint != nil { + if err := cutpoint("scratch-verified"); err != nil { + return err + } + } + if err := windows.CloseHandle(handle); err != nil { + return err + } + closed = true + if cutpoint != nil { + if err := cutpoint("before-publish"); err != nil { + return err + } + } + if err := moveNativePackageFile(scratch, path, false); err != nil { + return fmt.Errorf("atomically publish complete recovery marker preparation: %w", err) + } + published = true + if exists, err := readExactNativePackageRecoveryMarker(path, contents); err != nil { + return fmt.Errorf("validate atomically published recovery marker preparation: %w", err) + } else if !exists { + return errors.New("atomically published recovery marker preparation is absent") + } + if cutpoint != nil { + if err := cutpoint("after-publish"); err != nil { + return err + } + } + return nil +} + +func prepareNativePackageRecoveryMarker( + request nativePackageRecoverRequest, + trustBefore nativePackageRecoveryTrustCounts, +) (nativePackageRecoveryMarkerState, error) { + paths, err := resolveNativePackageRecoveryMarkerPaths() + if err != nil { + return nativePackageRecoveryMarkerState{}, err + } + contents, digest := canonicalNativePackageRecoveryMarker(request) + if !request.allowPartialCertificateState { + if err := retireNativePackageSettledRecoveryMarker(paths.settled); err != nil { + return nativePackageRecoveryMarkerState{}, fmt.Errorf( + "retire validated prior recovery-chain settlement: %w", err, + ) + } + } + preparing, err := readExactNativePackageRecoveryMarker(paths.preparing, contents) + if err != nil { + return nativePackageRecoveryMarkerState{}, fmt.Errorf("inspect preparing recovery marker: %w", err) + } + pending, err := readExactNativePackageRecoveryMarker(paths.pending, contents) + if err != nil { + return nativePackageRecoveryMarkerState{}, fmt.Errorf("inspect pending recovery marker: %w", err) + } + settled, err := readExactNativePackageRecoveryMarker(paths.settled, contents) + if err != nil { + return nativePackageRecoveryMarkerState{}, fmt.Errorf("inspect settled recovery marker: %w", err) + } + present := 0 + for _, exists := range []bool{preparing, pending, settled} { + if exists { + present++ + } + } + if err := validateNativePackageRecoveryMarkerAdmission( + present, request.allowPartialCertificateState, trustBefore, + ); err != nil { + return nativePackageRecoveryMarkerState{}, err + } + state := nativePackageRecoveryMarkerState{ + paths: paths, bytes: contents, sha256: digest, + wasSettled: settled, wasResuming: preparing || pending || settled, + } + if settled { + return state, nil + } + if preparing { + if err := moveNativePackageFile(paths.preparing, paths.pending, false); err != nil { + return nativePackageRecoveryMarkerState{}, fmt.Errorf( + "publish prepared failed-install recovery marker: %w", err, + ) + } + } else if !pending { + if err := createExactNativePackageRecoveryPreparation( + paths.preparing, contents, + ); err != nil { + return nativePackageRecoveryMarkerState{}, fmt.Errorf( + "create failed-install recovery marker preparation: %w", err, + ) + } + if err := moveNativePackageFile(paths.preparing, paths.pending, false); err != nil { + return nativePackageRecoveryMarkerState{}, fmt.Errorf( + "publish failed-install recovery pending marker: %w", err, + ) + } + } + if exists, err := readExactNativePackageRecoveryMarker(paths.pending, contents); err != nil { + return nativePackageRecoveryMarkerState{}, fmt.Errorf("validate published pending recovery marker: %w", err) + } else if !exists { + return nativePackageRecoveryMarkerState{}, errors.New("pending recovery marker publication was not durable") + } + return state, nil +} + +func settleNativePackageRecoveryMarker(state nativePackageRecoveryMarkerState) error { + if state.wasSettled { + if exists, err := readExactNativePackageRecoveryMarker( + state.paths.settled, state.bytes, + ); err != nil || !exists { + if err == nil { + err = errors.New("settled recovery marker disappeared") + } + return err + } + return nil + } + if err := moveNativePackageFile(state.paths.pending, state.paths.settled, false); err != nil { + return fmt.Errorf("atomically settle failed-install recovery marker: %w", err) + } + if exists, err := readExactNativePackageRecoveryMarker( + state.paths.settled, state.bytes, + ); err != nil || !exists { + if err == nil { + err = errors.New("settled recovery marker was not durably readable") + } + return err + } + for _, path := range []string{state.paths.preparing, state.paths.pending} { + if _, err := nativePathAttributes(path); err == nil { + return fmt.Errorf("live recovery marker remained after settlement: %s", path) + } else if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return fmt.Errorf("prove live recovery marker absent after settlement: %w", err) + } + } + return nil +} + +// requireNativePackageTrustRecoveryClear is called by every supported install +// path while the outer trust lease and package mutex are held. Presence of +// either pre-publication or pending state is a durable hard stop. An exact, +// protected, canonical settled marker is terminal evidence and is retired by +// handle so it cannot poison a later independent install/recovery chain. +func requireNativePackageTrustRecoveryClear() error { + paths, err := resolveNativePackageRecoveryMarkerPaths() + if err != nil { + return err + } + for _, path := range []string{paths.preparing, paths.pending} { + if _, err := nativePathAttributes(path); err == nil { + return fmt.Errorf("failed-install trust recovery remains pending at %s", path) + } else if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return fmt.Errorf("inspect failed-install trust recovery admission: %w", err) + } + } + if err := retireNativePackageSettledRecoveryMarker(paths.settled); err != nil { + return fmt.Errorf("retire prior settled failed-install recovery: %w", err) + } + return nil +} + +func requireNativePackageRecoveryNoLocalTrustOwner() error { + paths, err := resolveNativePackageRecoveryMarkerPaths() + if err != nil { + return err + } + for _, name := range []string{ + nativePackageLocalTrustPreparingName, + nativePackageLocalTrustPendingName, + nativePackageLocalTrustOwnedName, + nativePackageLocalTrustUninstallingName, + } { + path := filepath.Join(paths.directory, name) + if !strings.EqualFold(filepath.Dir(path), paths.directory) { + return errors.New("local-test trust ownership marker escaped its protected root") + } + if _, err := nativePathAttributes(path); err == nil { + return fmt.Errorf( + "local-test trust ownership remains active at %s; failed-install recovery has no deletion authority", + path, + ) + } else if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return fmt.Errorf("inspect local-test trust ownership admission: %w", err) + } + } + return nil +} + +// requireNativePackageRecoveryOuterTrustLease returns a lifetime guard so a +// native install child can prove it is nested inside a supported signed outer +// manager. The caller must hold the guard until its package transaction joins. +func requireNativePackageRecoveryOuterTrustLease() (func(), error) { + handle, directoryHandles, err := openNativePackageRecoveryTrustLease() + if err != nil { + return nil, err + } + if err := requireNativePackageRecoveryTrustLeaseHeld(handle); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + closeNativePackageUninstallHandles(directoryHandles) + return nil, err + } + return func() { + windows.CloseHandle(handle) //nolint:errcheck + closeNativePackageUninstallHandles(directoryHandles) + }, nil +} + +// openNativePackageRecoveryTrustLease proves that the signed outer manager +// owns the protected trust-transaction lease before this hidden native command +// can inspect or mutate LocalMachine trust. The manager creates the fixed +// ProgramData object with its ACL at creation time and holds byte zero locked +// across certificate mutation, the joined child, and cleanup. This command +// never creates, repairs, or takes ownership of either object. +func openNativePackageRecoveryTrustLease() (windows.Handle, []windows.Handle, error) { + paths, err := resolveNativePackageRecoveryMarkerPaths() + if err != nil { + return 0, nil, err + } + directory := paths.directory + path := paths.lease + directoryHandles, err := lockNativePackageDirectoryChain(directory) + if err != nil { + return 0, nil, fmt.Errorf("lock protected trust-lease directory chain: %w", err) + } + fail := func(cause error) (windows.Handle, []windows.Handle, error) { + closeNativePackageUninstallHandles(directoryHandles) + return 0, nil, cause + } + directoryHandle, err := openNativePathWithoutReparse( + directory, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if err != nil { + return fail(fmt.Errorf("open protected trust-lease directory: %w", err)) + } + if err := validateNativeSecurityDescriptor( + directoryHandle, nativePackageRecoveryTrustLeaseDirectorySDDL, + ); err != nil { + windows.CloseHandle(directoryHandle) //nolint:errcheck + return fail(fmt.Errorf("validate protected trust-lease directory: %w", err)) + } + directoryHandles = append(directoryHandles, directoryHandle) + + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return fail(err) + } + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_READ|windows.READ_CONTROL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return fail(fmt.Errorf("open protected trust-lease file: %w", err)) + } + closeAndFail := func(cause error) (windows.Handle, []windows.Handle, error) { + windows.CloseHandle(handle) //nolint:errcheck + return fail(cause) + } + information := windows.ByHandleFileInformation{} + if err := windows.GetFileInformationByHandle(handle, &information); err != nil { + return closeAndFail(fmt.Errorf("inspect protected trust-lease file: %w", err)) + } + if information.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 || + information.FileSizeHigh != 0 || information.FileSizeLow != 1 { + return closeAndFail(errors.New( + "protected trust-lease file is not the exact one-byte regular file", + )) + } + if err := validateNativeFileLinkCount(information.NumberOfLinks); err != nil { + return closeAndFail(fmt.Errorf("validate protected trust-lease link count: %w", err)) + } + if err := validateNativeSecurityDescriptor( + handle, nativePackageRecoveryTrustLeaseFileSDDL, + ); err != nil { + return closeAndFail(fmt.Errorf("validate protected trust-lease file: %w", err)) + } + if _, err := windows.SetFilePointer(handle, 0, nil, windows.FILE_BEGIN); err != nil { + return closeAndFail(fmt.Errorf("rewind protected trust-lease file: %w", err)) + } + marker := []byte{0} + var read uint32 + if err := windows.ReadFile(handle, marker, &read, nil); err != nil || + read != 1 || marker[0] != 1 { + if err == nil { + err = errors.New("trust-lease marker is not canonical") + } + return closeAndFail(fmt.Errorf("read protected trust-lease marker: %w", err)) + } + return handle, directoryHandles, nil +} + +func acquireNativePackageRecoveryTrustLease( + ctx context.Context, + deadline time.Time, +) (windows.Handle, []windows.Handle, error) { + handle, directoryHandles, err := openNativePackageRecoveryTrustLease() + if err != nil { + return 0, nil, err + } + fail := func(cause error) (windows.Handle, []windows.Handle, error) { + windows.CloseHandle(handle) //nolint:errcheck + closeNativePackageUninstallHandles(directoryHandles) + return 0, nil, cause + } + for { + overlapped := windows.Overlapped{} + err := windows.LockFileEx( + handle, + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, + 1, + 0, + &overlapped, + ) + if err == nil { + return handle, directoryHandles, nil + } + if !errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return fail(fmt.Errorf("acquire protected trust-lease byte: %w", err)) + } + remaining := time.Until(deadline) + if remaining <= 0 { + return fail(context.DeadlineExceeded) + } + pause := 50 * time.Millisecond + if remaining < pause { + pause = remaining + } + timer := time.NewTimer(pause) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return fail(ctx.Err()) + case <-timer.C: + } + } +} + +func releaseNativePackageRecoveryTrustLease( + handle windows.Handle, + directoryHandles []windows.Handle, +) error { + overlapped := windows.Overlapped{} + unlockErr := windows.UnlockFileEx(handle, 0, 1, 0, &overlapped) + closeErr := windows.CloseHandle(handle) + closeNativePackageUninstallHandles(directoryHandles) + if unlockErr != nil { + return fmt.Errorf("release protected trust-lease byte: %w", unlockErr) + } + if closeErr != nil { + return fmt.Errorf("close protected trust-lease file: %w", closeErr) + } + return nil +} + +func requireNativePackageRecoveryTrustLeaseHeld(handle windows.Handle) error { + overlapped := windows.Overlapped{} + err := windows.LockFileEx( + handle, + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, + 1, + 0, + &overlapped, + ) + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return nil + } + if err != nil { + return fmt.Errorf("probe protected trust-lease owner: %w", err) + } + unlockErr := windows.UnlockFileEx(handle, 0, 1, 0, &overlapped) + if unlockErr != nil { + return fmt.Errorf("release unauthorized trust-lease probe: %w", unlockErr) + } + return errors.New("native package recovery requires the signed outer manager to hold the protected trust lease") +} + +func lockNativePackageRecoveryBrokerJournalParent() ([]windows.Handle, error) { + programData, err := windows.KnownFolderPath( + windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT, + ) + if err != nil { + return nil, fmt.Errorf("resolve ProgramData broker-journal root: %w", err) + } + programData = filepath.Clean(programData) + product := filepath.Join(programData, "VIIPER") + root := filepath.Join(product, nativeBrokerJournalRootName) + if !strings.EqualFold(filepath.Dir(product), programData) || + !strings.EqualFold(filepath.Dir(root), product) { + return nil, errors.New("native broker journal escaped fixed ProgramData") + } + handles, err := lockNativePackageDirectoryChain(programData) + if err != nil { + return nil, fmt.Errorf("lock ProgramData for broker-journal absence: %w", err) + } + productHandle, err := openNativePathWithoutReparse( + product, windows.FILE_READ_ATTRIBUTES, true, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || + errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return handles, nil + } + closeNativePackageUninstallHandles(handles) + return nil, fmt.Errorf("open broker-journal product parent: %w", err) + } + handles = append(handles, productHandle) + rootHandle, err := openNativePathWithoutReparse( + root, windows.FILE_READ_ATTRIBUTES, true, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || + errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return handles, nil + } + closeNativePackageUninstallHandles(handles) + return nil, fmt.Errorf("prove broker-journal root absent: %w", err) + } + windows.CloseHandle(rootHandle) //nolint:errcheck + closeNativePackageUninstallHandles(handles) + return nil, errors.New( + "BrokerTransactions exists; exact R4 recordless recovery has no authority over any broker journal", + ) +} + +func readNativePackageRecoveryFile(handle windows.Handle, maximum uint64) ([]byte, error) { + information := windows.ByHandleFileInformation{} + if err := windows.GetFileInformationByHandle(handle, &information); err != nil { + return nil, err + } + size := uint64(information.FileSizeHigh)<<32 | uint64(information.FileSizeLow) + if size == 0 || size > maximum { + return nil, fmt.Errorf("bound recovery artifact length %d is outside 1..%d", size, maximum) + } + if _, err := windows.SetFilePointer(handle, 0, nil, windows.FILE_BEGIN); err != nil { + return nil, err + } + contents := make([]byte, int(size)) + position := 0 + for position < len(contents) { + var read uint32 + if err := windows.ReadFile(handle, contents[position:], &read, nil); err != nil { + return nil, err + } + if read == 0 { + return nil, errors.New("bound recovery artifact ended before its authenticated length") + } + position += int(read) + } + if _, err := windows.SetFilePointer(handle, 0, nil, windows.FILE_BEGIN); err != nil { + return nil, err + } + return contents, nil +} + +func openNativePackageRecoveryCertificateStore(name string) (windows.Handle, error) { + storeName, err := windows.UTF16PtrFromString(name) + if err != nil { + return 0, err + } + store, err := windows.CertOpenStore( + windows.CERT_STORE_PROV_SYSTEM_W, + 0, + 0, + windows.CERT_SYSTEM_STORE_LOCAL_MACHINE| + windows.CERT_STORE_OPEN_EXISTING_FLAG| + windows.CERT_STORE_MAXIMUM_ALLOWED_FLAG, + uintptr(unsafe.Pointer(storeName)), + ) + if err != nil { + return 0, err + } + return store, nil +} + +func nativePackageRecoveryCertificateMatches( + certificate *windows.CertContext, + expectedDER []byte, +) bool { + if certificate == nil || certificate.EncodedCert == nil || + uint64(certificate.Length) != uint64(len(expectedDER)) { + return false + } + return bytes.Equal( + unsafe.Slice(certificate.EncodedCert, int(certificate.Length)), + expectedDER, + ) +} + +func deleteExactNativePackageRecoveryCertificate( + store windows.Handle, + expectedDER []byte, +) error { + var previous *windows.CertContext + for enumerated := 0; enumerated < 65536; enumerated++ { + certificate, err := windows.CertEnumCertificatesInStore(store, previous) + if err != nil { + if errors.Is(err, syscall.Errno(windows.CRYPT_E_NOT_FOUND)) { + return errors.New("exact recovery certificate disappeared before its authorized deletion") + } + return err + } + previous = certificate + if nativePackageRecoveryCertificateMatches(certificate, expectedDER) { + // CertDeleteCertificateFromStore always frees certificate. Do not + // pass it back to CertEnumCertificatesInStore after this call. + return windows.CertDeleteCertificateFromStore(certificate) + } + } + if previous != nil { + windows.CertFreeCertificateContext(previous) //nolint:errcheck + } + return errors.New("certificate store deletion exceeded its safety bound") +} + +func validateNativePackageRecoveryTrustAdmission( + counts nativePackageRecoveryTrustCounts, + allowPartial bool, +) error { + if !allowPartial { + if counts.root != 1 || counts.trustedPublisher != 1 { + return fmt.Errorf("initial recovery requires exact trust counts Root=1 TrustedPublisher=1; observed Root=%d TrustedPublisher=%d", + counts.root, counts.trustedPublisher) + } + return nil + } + if counts.root < 0 || counts.root > 1 || counts.trustedPublisher < 0 || counts.trustedPublisher > 1 { + return fmt.Errorf("bound recovery retry permits only zero/one exact trust counts; observed Root=%d TrustedPublisher=%d", + counts.root, counts.trustedPublisher) + } + return nil +} + +func executeNativePackageRecoveryHelper( + helperPath string, + arguments []string, +) (string, int, error) { + command := exec.Command(helperPath, arguments...) + command.Dir = filepath.Dir(helperPath) + var output bytes.Buffer + command.Stdout = &output + command.Stderr = &output + if err := command.Start(); err != nil { + return "", 0, err + } + waitErr := waitNativePackageHelper(command) + exitCode := 0 + if waitErr != nil { + var exitError *exec.ExitError + if !errors.As(waitErr, &exitError) { + return output.String(), 0, waitErr + } + exitCode = exitError.ExitCode() + } + return output.String(), exitCode, waitErr +} + +func requireNativePackageRecoveryServiceAbsent(name string) error { + manager, err := mgr.Connect() + if err != nil { + return fmt.Errorf("connect to SCM for %s recovery admission: %w", name, err) + } + defer manager.Disconnect() //nolint:errcheck + service, err := manager.OpenService(name) + if err != nil { + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + return nil + } + return fmt.Errorf("inspect %s recovery admission: %w", name, err) + } + defer service.Close() //nolint:errcheck + return fmt.Errorf("service %s exists; a current or successor VIIPER topology blocks certificate cleanup", name) +} + +func validateNativePackageFailedInstallRecoveryCapability( + capability nativePackageFailedInstallRecoveryCapability, + request nativePackageRecoverRequest, + expectedLeasePath string, + parentPID uint32, + parentCreationFileTime uint64, +) error { + if capability.Schema != nativePackageFailedInstallRecoveryCapabilitySchema || + len(capability.Nonce) != 32 || capability.Nonce != strings.ToLower(capability.Nonce) { + return errors.New("failed-install recovery capability schema or nonce is noncanonical") + } + if _, err := hex.DecodeString(capability.Nonce); err != nil { + return errors.New("failed-install recovery capability nonce is not 128-bit lowercase hexadecimal") + } + if !strings.EqualFold(filepath.Clean(capability.LeasePath), expectedLeasePath) || + capability.SourceRevision != request.sourceRevision || + capability.HelperSHA256 != request.expectedHelperSHA256 || + capability.CertificateSHA256 != request.expectedCertificateSHA256 || + capability.RecoveryAuthorizationSHA256 != request.expectedRecoveryAuthorizationSHA256 || + capability.RecoveryRootAuthorizationSHA256 != request.recoveryRootAuthorizationSHA256 || + capability.PackageLockSHA256 != request.currentPackageLockSHA256 || + capability.BundleManifestSHA256 != request.currentBundleManifestSHA256 || + capability.AllowPartialCertificateState != request.allowPartialCertificateState { + return errors.New("failed-install recovery capability does not bind the exact lease, package, authorization, trust, and retry request") + } + if capability.ParentPID != parentPID || + capability.ParentCreationFileTime != parentCreationFileTime { + return errors.New("failed-install recovery capability was not issued by this broker process parent") + } + return nil +} + +func lockAndVerifyNativePackageFailedInstallRecoveryCapability( + request nativePackageRecoverRequest, +) ([]windows.Handle, windows.Handle, error) { + capabilityPath := filepath.Clean(request.recoveryCapability) + if filepath.Base(capabilityPath) != nativePackageFailedInstallRecoveryCapabilityName || + !strings.EqualFold(filepath.Dir(capabilityPath), filepath.Dir(request.brokerSource)) { + return nil, 0, errors.New( + "failed-install recovery capability is not the exact protected sibling of the staged broker", + ) + } + directoryHandles, err := lockNativePackageDirectoryChain(filepath.Dir(capabilityPath)) + if err != nil { + return nil, 0, fmt.Errorf("lock failed-install recovery capability directory chain: %w", err) + } + keepDirectories := false + defer func() { + if !keepDirectories { + closeNativePackageUninstallHandles(directoryHandles) + } + }() + capabilityHandle, err := lockNativePackageInput(capabilityPath) + if err != nil { + return nil, 0, fmt.Errorf("lock failed-install recovery capability: %w", err) + } + keepCapability := false + defer func() { + if !keepCapability { + windows.CloseHandle(capabilityHandle) //nolint:errcheck + } + }() + if err := validateNativeSecurityDescriptor( + capabilityHandle, nativePackageRecoveryTrustLeaseFileSDDL, + ); err != nil { + return nil, 0, fmt.Errorf("validate failed-install recovery capability security: %w", err) + } + digest, err := hashNativePackageHandle(capabilityHandle) + if err != nil { + return nil, 0, fmt.Errorf("hash failed-install recovery capability: %w", err) + } + if digest != request.expectedRecoveryCapabilitySHA256 { + return nil, 0, fmt.Errorf( + "failed-install recovery capability SHA-256=%s expected=%s", + digest, request.expectedRecoveryCapabilitySHA256, + ) + } + contents, err := readNativePackageCapabilityHandle( + capabilityHandle, nativePackageRecoveryMaximumMarkerBytes, + ) + if err != nil { + return nil, 0, fmt.Errorf("read failed-install recovery capability: %w", err) + } + capability := nativePackageFailedInstallRecoveryCapability{} + if err := decodeCanonicalNativeBrokerJSON( + contents, &capability, nativePackageRecoveryMaximumMarkerBytes, + ); err != nil { + return nil, 0, fmt.Errorf("decode canonical failed-install recovery capability: %w", err) + } + paths, err := resolveNativePackageRecoveryMarkerPaths() + if err != nil { + return nil, 0, err + } + parentPID, parentCreationFileTime, err := nativePackageParentIdentity() + if err != nil { + return nil, 0, err + } + if err := validateNativePackageFailedInstallRecoveryCapability( + capability, request, paths.lease, parentPID, parentCreationFileTime, + ); err != nil { + return nil, 0, err + } + keepDirectories = true + keepCapability = true + return directoryHandles, capabilityHandle, nil +} + +func lockAndValidateNativePackageR4RecoveryEvidence( + authorization nativePackageR4RecoveryAuthorization, +) ([]windows.Handle, []windows.Handle, error) { + predecessor := authorization.Predecessor + directoryHandles := make([]windows.Handle, 0) + fileHandles := make([]windows.Handle, 0, 5) + fail := func(cause error) ([]windows.Handle, []windows.Handle, error) { + closeNativePackageUninstallHandles(fileHandles) + closeNativePackageUninstallHandles(directoryHandles) + return nil, nil, cause + } + for _, directory := range []string{ + filepath.Dir(predecessor.StatePath), + predecessor.InstallEvidenceDirectory, + } { + handles, err := lockNativePackageDirectoryChain(directory) + if err != nil { + return fail(fmt.Errorf("lock exact R4 predecessor evidence directory chain: %w", err)) + } + directoryHandles = append(directoryHandles, handles...) + } + type evidenceFile struct { + label string + path string + digest string + state bool + } + evidence := []evidenceFile{ + {"state", predecessor.StatePath, predecessor.StateSHA256, true}, + {"command", filepath.Join(predecessor.InstallEvidenceDirectory, "command.json"), predecessor.CommandSHA256, false}, + {"result", filepath.Join(predecessor.InstallEvidenceDirectory, "result.json"), predecessor.ResultSHA256, false}, + {"stdout", filepath.Join(predecessor.InstallEvidenceDirectory, "stdout.log"), predecessor.StdoutSHA256, false}, + {"stderr", filepath.Join(predecessor.InstallEvidenceDirectory, "stderr.log"), predecessor.StderrSHA256, false}, + } + for _, item := range evidence { + handle, err := lockNativePackageInput(item.path) + if err != nil { + return fail(fmt.Errorf("lock exact R4 predecessor %s evidence: %w", item.label, err)) + } + fileHandles = append(fileHandles, handle) + digest, err := hashNativePackageHandle(handle) + if err != nil { + return fail(fmt.Errorf("hash exact R4 predecessor %s evidence: %w", item.label, err)) + } + if digest != item.digest { + return fail(fmt.Errorf( + "exact R4 predecessor %s SHA-256=%s expected=%s", + item.label, digest, item.digest, + )) + } + if !item.state { + continue + } + contents, err := readNativePackageRecoveryFile( + handle, nativePackageRecoveryMaximumEvidenceBytes, + ) + if err != nil { + return fail(fmt.Errorf("read exact R4 predecessor state: %w", err)) + } + state := nativePackageR4RecoveryStateIdentity{} + if err := json.Unmarshal(contents, &state); err != nil { + return fail(fmt.Errorf("parse exact R4 predecessor state identity: %w", err)) + } + if state.Schema != "viiper.windows11.validation-state/v1" || + !strings.EqualFold(state.Machine, authorization.Machine) || + state.TargetUserSID != authorization.TargetUserSID { + return fail(errors.New( + "exact R4 predecessor state does not bind the recovery machine and target user", + )) + } + } + return directoryHandles, fileHandles, nil +} + +func recoverNativePackage( + ctx context.Context, + logger *slog.Logger, + request nativePackageRecoverRequest, +) (resultErr error) { + _ = logger + deadline, err := nativePackageRecoverDeadline(ctx) + if err != nil { + return err + } + capabilityDirectoryHandles, capabilityHandle, err := + lockAndVerifyNativePackageFailedInstallRecoveryCapability(request) + if err != nil { + return fmt.Errorf("verify parent-bound failed-install recovery authority: %w", err) + } + defer closeNativePackageUninstallHandles(capabilityDirectoryHandles) + defer windows.CloseHandle(capabilityHandle) //nolint:errcheck + if err := initializeNativePackageRecoveryTrustLease(); err != nil { + return fmt.Errorf("initialize protected trust transaction: %w", err) + } + trustLease, trustLeaseDirectoryHandles, err := acquireNativePackageRecoveryTrustLease( + ctx, deadline, + ) + if err != nil { + return fmt.Errorf("acquire protected trust transaction: %w", err) + } + defer func() { + resultErr = errors.Join(resultErr, releaseNativePackageRecoveryTrustLease( + trustLease, trustLeaseDirectoryHandles, + )) + }() + packageBudget := time.Until(deadline) + releasePackage, err := acquireNamedNativePackageMutex(nativePackageMutexName, packageBudget) + if err != nil { + return fmt.Errorf("acquire native package recovery mutex: %w", err) + } + defer releasePackage() + + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package recovery canceled before service lock: %w", err) + } + serviceBudget := time.Until(deadline) + releaseService, err := acquireNativeInstallMutex(serviceBudget) + if err != nil { + return fmt.Errorf("acquire native broker service mutex after package mutex: %w", err) + } + defer releaseService() + + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package recovery canceled before helper verification: %w", err) + } + directoryHandles, err := lockNativePackageDirectoryChain(filepath.Dir(request.driverHelper)) + if err != nil { + return fmt.Errorf("lock packaged recovery helper directory chain: %w", err) + } + defer closeNativePackageUninstallHandles(directoryHandles) + helper, err := lockNativePackageInput(request.driverHelper) + if err != nil { + return fmt.Errorf("lock packaged recovery helper: %w", err) + } + defer windows.CloseHandle(helper) //nolint:errcheck + helperHash, err := hashNativePackageHandle(helper) + if err != nil { + return fmt.Errorf("hash packaged recovery helper: %w", err) + } + if !strings.EqualFold(helperHash, request.expectedHelperSHA256) { + return fmt.Errorf("packaged recovery helper SHA-256=%s expected=%s", + helperHash, request.expectedHelperSHA256) + } + if err := requireNativePackagePE(helper); err != nil { + return fmt.Errorf("validate packaged recovery helper image: %w", err) + } + certificateDirectoryHandles, err := lockNativePackageDirectoryChain( + filepath.Dir(request.certificatePath), + ) + if err != nil { + return fmt.Errorf("lock recovery certificate directory chain: %w", err) + } + defer closeNativePackageUninstallHandles(certificateDirectoryHandles) + certificate, err := lockNativePackageInput(request.certificatePath) + if err != nil { + return fmt.Errorf("lock exact recovery certificate: %w", err) + } + defer windows.CloseHandle(certificate) //nolint:errcheck + certificateHash, err := hashNativePackageHandle(certificate) + if err != nil { + return fmt.Errorf("hash exact recovery certificate: %w", err) + } + if !strings.EqualFold(certificateHash, request.expectedCertificateSHA256) { + return fmt.Errorf("recovery certificate SHA-256=%s expected=%s", + certificateHash, request.expectedCertificateSHA256) + } + certificateDER, err := readNativePackageRecoveryFile( + certificate, nativePackageRecoveryMaximumCertificateBytes, + ) + if err != nil { + return fmt.Errorf("read exact recovery certificate bytes: %w", err) + } + authorizationDirectoryHandles, err := lockNativePackageDirectoryChain( + filepath.Dir(request.recoveryAuthorization), + ) + if err != nil { + return fmt.Errorf("lock recovery authorization directory chain: %w", err) + } + defer closeNativePackageUninstallHandles(authorizationDirectoryHandles) + authorization, err := lockNativePackageInput(request.recoveryAuthorization) + if err != nil { + return fmt.Errorf("lock recovery authorization receipt: %w", err) + } + defer windows.CloseHandle(authorization) //nolint:errcheck + authorizationHash, err := hashNativePackageHandle(authorization) + if err != nil { + return fmt.Errorf("hash recovery authorization receipt: %w", err) + } + if !strings.EqualFold( + authorizationHash, request.expectedRecoveryAuthorizationSHA256, + ) { + return fmt.Errorf("recovery authorization SHA-256=%s expected=%s", + authorizationHash, request.expectedRecoveryAuthorizationSHA256) + } + authorizationBytes, err := readNativePackageRecoveryFile( + authorization, nativePackageRecoveryMaximumAuthorizationBytes, + ) + if err != nil { + return fmt.Errorf("read bounded recovery authorization receipt: %w", err) + } + hostname, err := os.Hostname() + if err != nil { + return fmt.Errorf("resolve current recovery machine identity: %w", err) + } + authorizationValue, err := validateNativePackageR4RecoveryAuthorization( + authorizationBytes, request, hostname, + ) + if err != nil { + return fmt.Errorf("validate exact R4 failed-install recovery authority: %w", err) + } + predecessorDirectoryHandles, predecessorFileHandles, err := + lockAndValidateNativePackageR4RecoveryEvidence(authorizationValue) + if err != nil { + return fmt.Errorf("lease exact retained R4 failed-install evidence: %w", err) + } + defer closeNativePackageUninstallHandles(predecessorFileHandles) + defer closeNativePackageUninstallHandles(predecessorDirectoryHandles) + + // Rehash the non-write/delete-shared image immediately before process + // creation. No broker/service inspection or removal occurs on this path. + sealedHash, err := hashNativePackageHandle(helper) + if err != nil { + return fmt.Errorf("rehash sealed packaged recovery helper: %w", err) + } + if !strings.EqualFold(sealedHash, request.expectedHelperSHA256) { + return errors.New("sealed packaged recovery helper changed before launch") + } + recoverArguments := []string{ + "recover-failed-install-recordless", "--transaction-deadline-unix-ms", + strconv.FormatInt(deadline.UnixMilli(), 10), + } + // Never use CommandContext or terminate the helper. This exact operation is + // verify-only and must return its single recordless-absence proof intact. + recoverOutput, processExitCode, waitErr := executeNativePackageRecoveryHelper( + request.driverHelper, recoverArguments, + ) + if recoverOutput != "" { + if _, err := os.Stdout.WriteString(recoverOutput); err != nil { + return fmt.Errorf("publish packaged recovery helper evidence: %w", err) + } + } + if waitErr != nil { + var exitError *exec.ExitError + if !errors.As(waitErr, &exitError) { + return fmt.Errorf("join packaged recovery helper: %w", waitErr) + } + } + _, proofErr := parseNativePackageRecoverProof(recoverOutput, processExitCode) + if proofErr != nil { + if waitErr != nil { + return fmt.Errorf("verify packaged recovery helper proof: %w (process: %v)", + proofErr, waitErr) + } + return fmt.Errorf("verify packaged recovery helper proof: %w", proofErr) + } + brokerJournalParentHandles, err := lockNativePackageRecoveryBrokerJournalParent() + if err != nil { + return &nativePackageRecoverExitError{ + cause: fmt.Errorf("successor-preservation admission rejected: %w", err), + exitCode: 4, + } + } + defer closeNativePackageUninstallHandles(brokerJournalParentHandles) + + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package recovery canceled before successor admission: %w", err) + } + statusOutput, statusExitCode, statusWaitErr := executeNativePackageRecoveryHelper( + request.driverHelper, []string{"status"}, + ) + if statusOutput != "" { + if _, err := os.Stdout.WriteString(statusOutput); err != nil { + return fmt.Errorf("publish packaged recovery status evidence: %w", err) + } + } + if statusWaitErr != nil { + var exitError *exec.ExitError + if !errors.As(statusWaitErr, &exitError) { + return fmt.Errorf("join packaged recovery status helper: %w", statusWaitErr) + } + } + if err := validateNativePackageRecoverEmptyStatus( + statusOutput, statusExitCode, + ); err != nil { + return &nativePackageRecoverExitError{ + cause: fmt.Errorf("successor-preservation admission rejected: %w", err), + exitCode: 4, + } + } + for _, serviceName := range []string{ + NativeBrokerServiceName, nativePackageRecoveryDriverServiceName, + } { + if err := requireNativePackageRecoveryServiceAbsent(serviceName); err != nil { + return &nativePackageRecoverExitError{ + cause: fmt.Errorf("successor-preservation admission rejected: %w", err), + exitCode: 4, + } + } + } + if err := requireNativePackageRecoveryNoLocalTrustOwner(); err != nil { + return &nativePackageRecoverExitError{ + cause: fmt.Errorf("successor-preservation admission rejected: %w", err), + exitCode: 4, + } + } + rootStore, err := openNativePackageRecoveryCertificateStore("Root") + if err != nil { + return fmt.Errorf("open LocalMachine Root for exact recovery: %w", err) + } + defer windows.CertCloseStore(rootStore, 0) //nolint:errcheck + trustedPublisherStore, err := openNativePackageRecoveryCertificateStore("TrustedPublisher") + if err != nil { + return fmt.Errorf("open LocalMachine TrustedPublisher for exact recovery: %w", err) + } + defer windows.CertCloseStore(trustedPublisherStore, 0) //nolint:errcheck + trustBefore, err := inspectNativePackageLocalTestTrust( + rootStore, trustedPublisherStore, certificateDER, + ) + if err != nil { + return err + } + if err := validateNativePackageRecoveryTrustAdmission( + trustBefore, request.allowPartialCertificateState, + ); err != nil { + return &nativePackageRecoverExitError{ + cause: fmt.Errorf("successor-preservation admission rejected: %w", err), + exitCode: 4, + } + } + markerState, err := prepareNativePackageRecoveryMarker(request, trustBefore) + if err != nil { + return &nativePackageRecoverExitError{ + cause: fmt.Errorf("publish durable failed-install recovery authority: %w", err), + exitCode: 4, + } + } + if markerState.wasSettled && + (trustBefore.root != 0 || trustBefore.trustedPublisher != 0) { + return &nativePackageRecoverExitError{ + cause: errors.New( + "settled failed-install recovery marker requires exact trust counts Root=0 TrustedPublisher=0", + ), + exitCode: 4, + } + } + if trustBefore.root == 1 { + if err := deleteExactNativePackageRecoveryCertificate(rootStore, certificateDER); err != nil { + return fmt.Errorf("delete exact LocalMachine Root recovery certificate: %w", err) + } + } + if trustBefore.trustedPublisher == 1 { + if err := deleteExactNativePackageRecoveryCertificate( + trustedPublisherStore, certificateDER, + ); err != nil { + return fmt.Errorf("delete exact LocalMachine TrustedPublisher recovery certificate: %w", err) + } + } + trustAfter, err := inspectNativePackageLocalTestTrust( + rootStore, trustedPublisherStore, certificateDER, + ) + if err != nil { + return err + } + if trustAfter.root != 0 || trustAfter.trustedPublisher != 0 { + return errors.New("exact failed-install certificate remained after native locked recovery") + } + if err := settleNativePackageRecoveryMarker(markerState); err != nil { + return fmt.Errorf("settle durable failed-install recovery authority: %w", err) + } + resume := 0 + if request.allowPartialCertificateState { + resume = 1 + } + if _, err := fmt.Fprintf(os.Stdout, + "recovery-receipt operation=native-package-recover activeJournal=0 devices=0 packages=0 brokerService=0 driverService=0 successor=0 trustRootBefore=%d trustTrustedPublisherBefore=%d trustRootAfter=0 trustTrustedPublisherAfter=0 marker=settled markerSha256=%s rootAuthorizationSha256=%s authorizationSha256=%s certificateSha256=%s sourceRevision=%s resume=%d\n", + trustBefore.root, trustBefore.trustedPublisher, + markerState.sha256, + request.recoveryRootAuthorizationSHA256, + request.expectedRecoveryAuthorizationSHA256, + request.expectedCertificateSHA256, request.sourceRevision, resume, + ); err != nil { + return fmt.Errorf("publish native package recovery receipt: %w", err) + } + return nil +} diff --git a/internal/cmd/native_package_recover_windows_test.go b/internal/cmd/native_package_recover_windows_test.go new file mode 100644 index 00000000..71ebee60 --- /dev/null +++ b/internal/cmd/native_package_recover_windows_test.go @@ -0,0 +1,412 @@ +//go:build windows + +package cmd + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +func TestNativePackageRecoveryTrustAdmission(t *testing.T) { + t.Parallel() + tests := []struct { + name string + counts nativePackageRecoveryTrustCounts + allowPartial bool + wantErr bool + }{ + {"initial exact", nativePackageRecoveryTrustCounts{1, 1}, false, false}, + {"initial missing root", nativePackageRecoveryTrustCounts{0, 1}, false, true}, + {"initial already absent", nativePackageRecoveryTrustCounts{0, 0}, false, true}, + {"retry untouched", nativePackageRecoveryTrustCounts{1, 1}, true, false}, + {"retry root removed", nativePackageRecoveryTrustCounts{0, 1}, true, false}, + {"retry publisher removed", nativePackageRecoveryTrustCounts{1, 0}, true, false}, + {"retry complete", nativePackageRecoveryTrustCounts{0, 0}, true, false}, + {"retry duplicate", nativePackageRecoveryTrustCounts{2, 1}, true, true}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + err := validateNativePackageRecoveryTrustAdmission(test.counts, test.allowPartial) + if (err != nil) != test.wantErr { + t.Fatalf("error=%v wantErr=%v", err, test.wantErr) + } + }) + } +} + +func TestNativePackageFailedInstallRecoveryCapabilityBinding(t *testing.T) { + t.Parallel() + request := validNativePackageRecoverRequest() + lease := `C:\ProgramData\VIIPER-TrustManager\lease-v1.lock` + capability := nativePackageFailedInstallRecoveryCapability{ + Schema: nativePackageFailedInstallRecoveryCapabilitySchema, + Nonce: strings.Repeat("a", 32), + ParentPID: 1234, + ParentCreationFileTime: 5678, + LeasePath: lease, + SourceRevision: request.sourceRevision, + HelperSHA256: request.expectedHelperSHA256, + CertificateSHA256: request.expectedCertificateSHA256, + RecoveryAuthorizationSHA256: request.expectedRecoveryAuthorizationSHA256, + RecoveryRootAuthorizationSHA256: request.recoveryRootAuthorizationSHA256, + PackageLockSHA256: request.currentPackageLockSHA256, + BundleManifestSHA256: request.currentBundleManifestSHA256, + AllowPartialCertificateState: request.allowPartialCertificateState, + } + if err := validateNativePackageFailedInstallRecoveryCapability( + capability, request, lease, 1234, 5678, + ); err != nil { + t.Fatalf("valid recovery capability rejected: %v", err) + } + mutations := map[string]func(*nativePackageFailedInstallRecoveryCapability){ + "schema": func(v *nativePackageFailedInstallRecoveryCapability) { v.Schema = "v2" }, + "nonce": func(v *nativePackageFailedInstallRecoveryCapability) { v.Nonce = strings.Repeat("A", 32) }, + "parent PID": func(v *nativePackageFailedInstallRecoveryCapability) { v.ParentPID++ }, + "parent creation": func(v *nativePackageFailedInstallRecoveryCapability) { v.ParentCreationFileTime++ }, + "lease": func(v *nativePackageFailedInstallRecoveryCapability) { v.LeasePath += ".other" }, + "source": func(v *nativePackageFailedInstallRecoveryCapability) { v.SourceRevision = strings.Repeat("3", 40) }, + "helper": func(v *nativePackageFailedInstallRecoveryCapability) { v.HelperSHA256 = strings.Repeat("4", 64) }, + "certificate": func(v *nativePackageFailedInstallRecoveryCapability) { v.CertificateSHA256 = strings.Repeat("5", 64) }, + "authorization": func(v *nativePackageFailedInstallRecoveryCapability) { + v.RecoveryAuthorizationSHA256 = strings.Repeat("6", 64) + }, + "root authority": func(v *nativePackageFailedInstallRecoveryCapability) { + v.RecoveryRootAuthorizationSHA256 = strings.Repeat("7", 64) + }, + "package lock": func(v *nativePackageFailedInstallRecoveryCapability) { v.PackageLockSHA256 = strings.Repeat("8", 64) }, + "bundle": func(v *nativePackageFailedInstallRecoveryCapability) { + v.BundleManifestSHA256 = strings.Repeat("9", 64) + }, + "retry": func(v *nativePackageFailedInstallRecoveryCapability) { v.AllowPartialCertificateState = true }, + } + for name, mutate := range mutations { + name, mutate := name, mutate + t.Run(name, func(t *testing.T) { + t.Parallel() + changed := capability + mutate(&changed) + if err := validateNativePackageFailedInstallRecoveryCapability( + changed, request, lease, 1234, 5678, + ); err == nil { + t.Fatal("mismatched recovery capability was admitted") + } + }) + } +} + +func TestNativePackageR4RecoveryRejectsMissingRetainedEvidence(t *testing.T) { + t.Parallel() + request := validNativePackageRecoverRequest() + request.expectedCertificateSHA256 = nativePackageR4CertificateSHA256 + authorization := validNativePackageR4RecoveryAuthorization( + request, "VIIPER-R4-CONTRACT", + ) + root := t.TempDir() + authorization.Predecessor.StatePath = filepath.Join(root, "missing-state.json") + authorization.Predecessor.InstallEvidenceDirectory = filepath.Join(root, "missing-step") + directoryHandles, fileHandles, err := + lockAndValidateNativePackageR4RecoveryEvidence(authorization) + if err == nil { + closeNativePackageUninstallHandles(fileHandles) + closeNativePackageUninstallHandles(directoryHandles) + t.Fatal("R4 recovery admitted absent retained predecessor evidence") + } +} + +func TestNativePackageRecoveryMarkerCrashCutAdmission(t *testing.T) { + t.Parallel() + tests := []struct { + name string + present int + allowPartial bool + counts nativePackageRecoveryTrustCounts + wantErr bool + }{ + {"initial before marker", 0, false, nativePackageRecoveryTrustCounts{1, 1}, false}, + {"retry crash before marker unchanged", 0, true, nativePackageRecoveryTrustCounts{1, 1}, false}, + {"retry unexplained root missing", 0, true, nativePackageRecoveryTrustCounts{0, 1}, true}, + {"retry unexplained publisher missing", 0, true, nativePackageRecoveryTrustCounts{1, 0}, true}, + {"retry after pending root missing", 1, true, nativePackageRecoveryTrustCounts{0, 1}, false}, + {"retry after pending both missing", 1, true, nativePackageRecoveryTrustCounts{0, 0}, false}, + {"initial cannot consume old marker", 1, false, nativePackageRecoveryTrustCounts{1, 1}, true}, + {"ambiguous marker states", 2, true, nativePackageRecoveryTrustCounts{0, 0}, true}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + err := validateNativePackageRecoveryMarkerAdmission( + test.present, test.allowPartial, test.counts, + ) + if (err != nil) != test.wantErr { + t.Fatalf("error=%v wantErr=%v", err, test.wantErr) + } + }) + } +} + +func TestNativePackageRecoverySettledMarkerRetiresForLaterChain(t *testing.T) { + requireNativeMutexAdministrator(t) + path := t.TempDir() + `\failed-install-recovery-settled-v1.json` + first := validNativePackageRecoverRequest() + first.recoveryRootAuthorizationSHA256 = strings.Repeat("a", 64) + firstBytes, _ := canonicalNativePackageRecoveryMarker(first) + if err := createExactNativePackageRecoveryPreparation(path, firstBytes); err != nil { + t.Fatalf("create first settled marker: %v", err) + } + if err := retireNativePackageSettledRecoveryMarker(path); err != nil { + t.Fatalf("retire first settled marker: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("retired marker remains: %v", err) + } + second := first + second.recoveryRootAuthorizationSHA256 = strings.Repeat("b", 64) + secondBytes, _ := canonicalNativePackageRecoveryMarker(second) + if err := createExactNativePackageRecoveryPreparation(path, secondBytes); err != nil { + t.Fatalf("later independent recovery chain remained poisoned: %v", err) + } +} + +func TestNativePackageRecoveryPreparationPublishesOnlyCompleteBytes(t *testing.T) { + requireNativeMutexAdministrator(t) + request := validNativePackageRecoverRequest() + contents, _ := canonicalNativePackageRecoveryMarker(request) + prepublish := []string{ + "scratch-created", "scratch-written", "scratch-flushed", + "scratch-verified", "before-publish", + } + for _, stage := range prepublish { + stage := stage + t.Run(stage, func(t *testing.T) { + path := filepath.Join(t.TempDir(), nativePackageRecoveryMarkerPreparingName) + cutErr := errors.New("injected cut " + stage) + err := createExactNativePackageRecoveryPreparationWithCutpoint( + path, contents, + func(current string) error { + if current == stage { + return cutErr + } + return nil + }, + ) + if !errors.Is(err, cutErr) { + t.Fatalf("cut error=%v want=%v", err, cutErr) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("canonical marker became visible before complete publication: %v", err) + } + entries, err := os.ReadDir(filepath.Dir(path)) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("graceful cut retained preparation scratch: %v", entries) + } + }) + } + + path := filepath.Join(t.TempDir(), nativePackageRecoveryMarkerPreparingName) + afterPublish := errors.New("injected cut after publish") + err := createExactNativePackageRecoveryPreparationWithCutpoint( + path, contents, + func(current string) error { + if current == "after-publish" { + return afterPublish + } + return nil + }, + ) + if !errors.Is(err, afterPublish) { + t.Fatalf("post-publication cut error=%v", err) + } + if exists, err := readExactNativePackageRecoveryMarker(path, contents); err != nil || !exists { + t.Fatalf("post-publication cut did not leave exact resumable canonical bytes: exists=%v error=%v", exists, err) + } +} + +func TestNativePackageRecoverySettledMarkerRejectsNoncanonicalBytes(t *testing.T) { + requireNativeMutexAdministrator(t) + path := t.TempDir() + `\failed-install-recovery-settled-v1.json` + request := validNativePackageRecoverRequest() + contents, _ := canonicalNativePackageRecoveryMarker(request) + contents = append(contents, '\n') + if err := createExactNativePackageRecoveryPreparation(path, contents); err != nil { + t.Fatalf("create malformed settled marker: %v", err) + } + if err := retireNativePackageSettledRecoveryMarker(path); err == nil { + t.Fatal("noncanonical settled marker was deleted") + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("noncanonical settled marker was not preserved fail-closed: %v", err) + } +} + +func TestNativePackageRecoveryPackageMutexExcludesSuccessor(t *testing.T) { + requireNativeMutexAdministrator(t) + firstRelease, err := acquireNamedNativePackageMutex(nativePackageMutexName, time.Second) + if err != nil { + t.Fatalf("acquire recovery package mutex: %v", err) + } + acquired := make(chan func(), 1) + errors := make(chan error, 1) + go func() { + release, acquireErr := acquireNamedNativePackageMutex(nativePackageMutexName, 3*time.Second) + if acquireErr != nil { + errors <- acquireErr + return + } + acquired <- release + }() + select { + case release := <-acquired: + release() + firstRelease() + t.Fatal("concurrent successor acquired the package mutex before recovery released it") + case err := <-errors: + firstRelease() + t.Fatalf("concurrent package mutex waiter failed early: %v", err) + case <-time.After(100 * time.Millisecond): + } + firstRelease() + select { + case release := <-acquired: + release() + case err := <-errors: + t.Fatalf("concurrent package mutex waiter failed: %v", err) + case <-time.After(3 * time.Second): + t.Fatal("concurrent successor did not acquire the package mutex after recovery release") + } +} + +func TestNativePackageRecoveryTrustLeaseRequiresAnotherOwner(t *testing.T) { + path := t.TempDir() + `\lease-v1.lock` + if err := os.WriteFile(path, []byte{1}, 0o600); err != nil { + t.Fatal(err) + } + first, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + defer first.Close() + second, err := os.OpenFile(path, os.O_RDONLY, 0) + if err != nil { + t.Fatal(err) + } + defer second.Close() + overlapped := windows.Overlapped{} + if err := windows.LockFileEx( + windows.Handle(first.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK, + 0, 1, 0, &overlapped, + ); err != nil { + t.Fatal(err) + } + if err := requireNativePackageRecoveryTrustLeaseHeld( + windows.Handle(second.Fd()), + ); err != nil { + t.Fatalf("another-owner lease proof rejected: %v", err) + } + if err := windows.UnlockFileEx( + windows.Handle(first.Fd()), 0, 1, 0, &overlapped, + ); err != nil { + t.Fatal(err) + } + err = requireNativePackageRecoveryTrustLeaseHeld(windows.Handle(second.Fd())) + if err == nil || !strings.Contains(err.Error(), "outer manager") { + t.Fatalf("unowned lease admitted: %v", err) + } + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + t.Fatalf("unowned lease leaked raw lock status: %v", err) + } +} + +func TestNativePackageRecoverySourceKeepsTrustMutationInsideLocks(t *testing.T) { + sourceBytes, err := os.ReadFile("native_package_recover_windows.go") + if err != nil { + t.Fatal(err) + } + source := strings.ReplaceAll(string(sourceBytes), "\r\n", "\n") + recoveryStart := strings.Index(source, "func recoverNativePackage(") + if recoveryStart < 0 { + t.Fatal("recovery implementation is missing") + } + recoverySource := source[recoveryStart:] + ordered := []string{ + "lockAndVerifyNativePackageFailedInstallRecoveryCapability(request)", + "acquireNativePackageRecoveryTrustLease(", + "acquireNamedNativePackageMutex(nativePackageMutexName", + "acquireNativeInstallMutex(serviceBudget)", + "validateNativePackageR4RecoveryAuthorization(", + "lockAndValidateNativePackageR4RecoveryEvidence(authorizationValue)", + "lockNativePackageRecoveryBrokerJournalParent()", + `[]string{"status"}`, + "requireNativePackageRecoveryServiceAbsent(serviceName)", + "requireNativePackageRecoveryNoLocalTrustOwner()", + "inspectNativePackageLocalTestTrust(", + "prepareNativePackageRecoveryMarker(request, trustBefore)", + "deleteExactNativePackageRecoveryCertificate(rootStore", + "deleteExactNativePackageRecoveryCertificate(\n\t\t\ttrustedPublisherStore", + "settleNativePackageRecoveryMarker(markerState)", + `"recovery-receipt operation=native-package-recover`, + } + position := -1 + for _, fragment := range ordered { + next := strings.Index(recoverySource, fragment) + if next <= position { + t.Fatalf("recovery lock/mutation contract lost ordered fragment %q", fragment) + } + position = next + } + for _, required := range []string{ + "VIIPER-TrustManager", + "lease-v1.lock", + "O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)", + "O:BAG:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)", + "LOCKFILE_FAIL_IMMEDIATELY", + "CERT_STORE_MAXIMUM_ALLOWED_FLAG", + "CertEnumCertificatesInStore", + "CertDeleteCertificateFromStore", + "expectedRecoveryAuthorizationSHA256", + "lockNativePackageInput(item.path)", + "hashNativePackageHandle(handle)", + "defer closeNativePackageUninstallHandles(predecessorFileHandles)", + "defer closeNativePackageUninstallHandles(predecessorDirectoryHandles)", + `"recover-failed-install-recordless", "--transaction-deadline-unix-ms"`, + "BrokerTransactions exists; exact R4 recordless recovery has no authority", + "local-test-trust-owned-v1.json", + "trustRootAfter=0 trustTrustedPublisherAfter=0", + } { + if !strings.Contains(source, required) { + t.Fatalf("recovery source lost %q", required) + } + } + if strings.Contains(source, `[]string{"remove"}`) || + strings.Contains(source, `[]string{"recover"}`) || + strings.Contains(source, `"uninstall", "--yes"`) { + t.Fatal("journal-only recovery gained a remove/uninstall command") + } + if strings.Contains(recoverySource, "inspectNativePackageRecoveryTrust(") { + t.Fatal("recovery retained the collision-blind certificate inspector") + } + sharedBytes, err := os.ReadFile("native_package_windows.go") + if err != nil { + t.Fatal(err) + } + shared := strings.ReplaceAll(string(sharedBytes), "\r\n", "\n") + for _, collisionContract := range []string{ + "countExactNativePackageLocalTestCertificateRejectingThumbprintCollisions", + "different certificate with the same Windows SHA-1 thumbprint", + } { + if !strings.Contains(shared, collisionContract) { + t.Fatalf("shared collision-rejecting trust inspector lost %q", collisionContract) + } + } +} diff --git a/internal/cmd/native_package_test.go b/internal/cmd/native_package_test.go index 384d0da6..91de4476 100644 --- a/internal/cmd/native_package_test.go +++ b/internal/cmd/native_package_test.go @@ -7,6 +7,7 @@ import ( "io" "log/slog" "reflect" + "strconv" "strings" "testing" ) @@ -341,6 +342,53 @@ func TestNativePackageRebootRequiredPreservesInstallerExitCode(t *testing.T) { } } +func TestNativePackageSettledFailurePreservesInstallerExitCode(t *testing.T) { + t.Parallel() + for _, exitCode := range []int{1, 3, 4} { + exitCode := exitCode + t.Run(strconv.Itoa(exitCode), func(t *testing.T) { + t.Parallel() + err := &nativePackageInstallExitError{ + cause: errors.New("settled helper failure"), + exitCode: exitCode, + } + var exitCoder interface{ ExitCode() int } + if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != exitCode { + t.Fatalf("settled helper error lost exit %d contract: %v", exitCode, err) + } + }) + } +} + +func TestNativePackageProductionLocalTrustAdmission(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + states []string + wantRetire bool + wantErr bool + }{ + {name: "absent"}, + {name: "cleared", states: []string{"cleared"}, wantRetire: true}, + {name: "preparing", states: []string{"preparing"}, wantErr: true}, + {name: "pending", states: []string{"pending"}, wantErr: true}, + {name: "owned", states: []string{"owned"}, wantErr: true}, + {name: "uninstalling", states: []string{"uninstalling"}, wantErr: true}, + {name: "multiple", states: []string{"cleared", "owned"}, wantErr: true}, + {name: "unknown", states: []string{"future"}, wantErr: true}, + } { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + retire, err := nativePackageProductionLocalTrustAdmission(test.states) + if (err != nil) != test.wantErr || retire != test.wantRetire { + t.Fatalf("states=%v retire=%v error=%v wantRetire=%v wantErr=%v", + test.states, retire, err, test.wantRetire, test.wantErr) + } + }) + } +} + func TestNativePackageRequestFailsClosed(t *testing.T) { t.Parallel() base := nativePackageRequest{ @@ -379,4 +427,29 @@ func TestNativePackageRequestFailsClosed(t *testing.T) { } }) } + localTest := base + localTest.driverValidationMode = "local-test" + localTest.localTestTrustCapability = `C:\ProgramData\VIIPER.LocalTestStage.0123456789abcdef0123456789abcdef\local-test-trust-capability.json` + localTest.expectedTrustCapabilitySHA256 = strings.Repeat("1", 64) + localTest.localTestCertificatePath = `C:\bundle\ViiperUdeTest.cer` + localTest.expectedLocalTestCertificateSHA256 = strings.Repeat("2", 64) + localTest.expectedLocalTestPackageLockSHA256 = strings.Repeat("3", 64) + if err := localTest.validate(); err != nil { + t.Fatalf("valid local-test request: %v", err) + } + missingCapability := localTest + missingCapability.localTestTrustCapability = "" + if err := missingCapability.validate(); err == nil { + t.Fatal("local-test request without parent-bound trust capability accepted") + } + missingCertificate := localTest + missingCertificate.localTestCertificatePath = "" + if err := missingCertificate.validate(); err == nil { + t.Fatal("local-test request without exact certificate path accepted") + } + productionWithCapability := localTest + productionWithCapability.driverValidationMode = "production" + if err := productionWithCapability.validate(); err == nil { + t.Fatal("production request carrying local-test trust authority accepted") + } } diff --git a/internal/cmd/native_package_uninstall.go b/internal/cmd/native_package_uninstall.go index 3c4d9386..d208016a 100644 --- a/internal/cmd/native_package_uninstall.go +++ b/internal/cmd/native_package_uninstall.go @@ -23,9 +23,54 @@ const ( ) type nativePackageUninstallRequest struct { - driverHelper string - expectedHelperSHA256 string - targetUserSID string + driverHelper string + expectedHelperSHA256 string + targetUserSID string + sourceRevision string + localTestCertificatePath string + expectedLocalTestCertificateSHA256 string + expectedLocalTestPackageLockSHA256 string +} + +func nativePackageLocalTestUninstallMayMutateTopology(state string) bool { + switch state { + case "pending", "owned", "uninstalling": + return true + default: + return false + } +} + +// nativePackageProductionUninstallLocalTrustAdmission classifies local-test +// ownership while the production uninstall holds Trust -> Package -> Service. +// A production uninstall has no source-bound local certificate authority, so +// only no journal or one validated terminal settlement may proceed. +func nativePackageProductionUninstallLocalTrustAdmission(states []string) error { + if len(states) == 0 { + return nil + } + if len(states) != 1 { + return errors.New("multiple local-test trust ownership states block production uninstall") + } + switch states[0] { + case "cleared": + return nil + case "preparing", "pending", "owned", "uninstalling": + return fmt.Errorf( + "active local-test trust %s state requires exact source-bound uninstall identity", + states[0], + ) + default: + return fmt.Errorf( + "unknown local-test trust %q state blocks production uninstall", states[0], + ) + } +} + +func (r nativePackageUninstallRequest) localTestTrustRequested() bool { + return r.sourceRevision != "" || r.localTestCertificatePath != "" || + r.expectedLocalTestCertificateSHA256 != "" || + r.expectedLocalTestPackageLockSHA256 != "" } func (r nativePackageUninstallRequest) validate() error { @@ -35,8 +80,29 @@ func (r nativePackageUninstallRequest) validate() error { if strings.TrimSpace(r.targetUserSID) == "" { return errors.New("native package uninstall target user SID is empty") } - if strings.IndexByte(r.driverHelper, 0) >= 0 || strings.IndexByte(r.targetUserSID, 0) >= 0 { - return errors.New("native package uninstall input contains NUL") + for _, value := range []string{ + r.driverHelper, r.targetUserSID, r.sourceRevision, + r.localTestCertificatePath, r.expectedLocalTestCertificateSHA256, + r.expectedLocalTestPackageLockSHA256, + } { + if strings.IndexByte(value, 0) >= 0 { + return errors.New("native package uninstall input contains NUL") + } + } + if r.localTestTrustRequested() { + if !nativePackageHexRevision.MatchString(r.sourceRevision) { + return errors.New("native package local-test uninstall source revision must contain exactly 40 or 64 hexadecimal characters") + } + if !filepath.IsAbs(r.localTestCertificatePath) || + !strings.EqualFold(filepath.Base(r.localTestCertificatePath), "ViiperUdeTest.cer") { + return errors.New("native package local-test uninstall certificate must be an absolute ViiperUdeTest.cer path") + } + if !nativePackageSHA256.MatchString(r.expectedLocalTestCertificateSHA256) { + return errors.New("native package local-test uninstall certificate SHA-256 must contain exactly 64 hexadecimal characters") + } + if !nativePackageSHA256.MatchString(r.expectedLocalTestPackageLockSHA256) { + return errors.New("native package local-test uninstall package-lock SHA-256 must contain exactly 64 hexadecimal characters") + } } if !filepath.IsAbs(r.driverHelper) { return fmt.Errorf("native package uninstall driver helper must be an absolute path: %s", r.driverHelper) @@ -481,7 +547,21 @@ func (e *nativePackageUninstallUnsafeRestoreError) Unwrap() error { return e.cause } +// nativePackageUninstallAlreadySettledError is returned only after a +// source-bound local-test request proves, under Trust -> Package -> Service, +// that no driver package/device, service, or broker journal remains. The runner +// treats it as terminal idempotent success before entering any generic broker +// file inspection or topology mutation. +type nativePackageUninstallAlreadySettledError struct { + state string +} + +func (e *nativePackageUninstallAlreadySettledError) Error() string { + return fmt.Sprintf("local-test uninstall is already settled in %s state", e.state) +} + type nativePackageUninstallTransaction interface { + LockTrust(context.Context) error LockPackage(context.Context) error LockService(context.Context) error Preflight(context.Context) error @@ -489,6 +569,7 @@ type nativePackageUninstallTransaction interface { StopService(context.Context, nativePackageUninstallServiceSnapshot) error RemoveDriver(context.Context) (nativePackageRemoveResult, error) Cleanup(context.Context, nativePackageUninstallServiceSnapshot) (bool, error) + FinalizeTrust(context.Context) error RestoreService(context.Context, nativePackageUninstallServiceSnapshot) error Close() error } @@ -507,6 +588,12 @@ func runNativePackageUninstallTransaction( fmt.Errorf("close native package uninstall transaction: %w", closeErr)) } }() + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package uninstall canceled before trust lock: %w", err) + } + if err := transaction.LockTrust(ctx); err != nil { + return fmt.Errorf("acquire native package uninstall trust transaction: %w", err) + } if err := ctx.Err(); err != nil { return fmt.Errorf("native package uninstall canceled before package lock: %w", err) } @@ -523,6 +610,10 @@ func runNativePackageUninstallTransaction( return fmt.Errorf("native package uninstall canceled before preflight: %w", err) } if err := transaction.Preflight(ctx); err != nil { + var alreadySettled *nativePackageUninstallAlreadySettledError + if errors.As(err, &alreadySettled) { + return nil + } return fmt.Errorf("native package uninstall preflight rejected before mutation: %w", err) } if err := ctx.Err(); err != nil { @@ -605,6 +696,9 @@ func runNativePackageUninstallTransaction( if removeResult.rebootRequired || cleanupRebootRequired { return &nativePackageUninstallRebootRequiredError{} } + if err := transaction.FinalizeTrust(cleanupCtx); err != nil { + return fmt.Errorf("finalize exact local-test trust after topology removal: %w", err) + } return nil } diff --git a/internal/cmd/native_package_uninstall_contract_test.go b/internal/cmd/native_package_uninstall_contract_test.go new file mode 100644 index 00000000..3038fe8a --- /dev/null +++ b/internal/cmd/native_package_uninstall_contract_test.go @@ -0,0 +1,143 @@ +package cmd + +import ( + "os" + "strings" + "testing" +) + +func TestNativePackageLocalTestUninstallSourceContract(t *testing.T) { + t.Parallel() + windowsSourceBytes, err := os.ReadFile("native_package_uninstall_windows.go") + if err != nil { + t.Fatal(err) + } + windowsSource := string(windowsSourceBytes) + for _, fragment := range []string{ + "func (t *windowsNativePackageUninstallTransaction) LockTrust", + "initializeNativePackageRecoveryTrustLease()", + "acquireNativePackageRecoveryTrustLease(ctx, deadline)", + "prepareLocalTestTrustUninstall(ctx)", + "inspectNativePackageLocalTestTrust(", + "transitionNativePackageLocalTestTrustRecord(", + "paths.uninstalling", + "proveNativePackageLocalTestTopologyAbsent(", + "restoreNativePackageLocalTestTrustStores(", + "t.trustPaths.cleared", + "release local-test trust transaction", + } { + if !strings.Contains(windowsSource, fragment) { + t.Fatalf("Windows local-test uninstall lost %q", fragment) + } + } + if strings.Contains(windowsSource, "inspectNativePackageRecoveryTrust(") { + t.Fatal("local-test uninstall must reject Windows SHA-1 thumbprint collisions") + } + preflightStart := strings.Index(windowsSource, + "func (t *windowsNativePackageUninstallTransaction) Preflight") + inspectStart := strings.Index(windowsSource, + "func (t *windowsNativePackageUninstallTransaction) InspectService") + if preflightStart < 0 || inspectStart <= preflightStart { + t.Fatal("Windows uninstall preflight region is malformed") + } + preflight := windowsSource[preflightStart:inspectStart] + trustAdmission := strings.Index(preflight, "prepareLocalTestTrustUninstall(ctx)") + brokerReconciliation := strings.Index(preflight, + "reconcileNativeBrokerJournalBeforeAdmission(ctx") + if trustAdmission < 0 || brokerReconciliation <= trustAdmission { + t.Fatal("broker reconciliation can precede durable local-test uninstall authority") + } + finalizeStart := strings.Index(windowsSource, + "func (t *windowsNativePackageUninstallTransaction) FinalizeTrust") + deleteStart := strings.Index(windowsSource, + "func deleteNativePackageUninstallFileHandle") + if finalizeStart < 0 || deleteStart <= finalizeStart { + t.Fatal("Windows uninstall trust-finalization region is malformed") + } + finalize := windowsSource[finalizeStart:deleteStart] + proof := strings.Index(finalize, "proveNativePackageLocalTestTopologyAbsent(") + restore := strings.Index(finalize, "restoreNativePackageLocalTestTrustStores(") + cleared := strings.Index(finalize, "t.trustPaths.cleared") + if proof < 0 || restore <= proof || cleared <= restore { + t.Fatal("trust baseline restore/cleared publication can precede exact topology proof") + } + closeStart := strings.Index(windowsSource, + "func (t *windowsNativePackageUninstallTransaction) Close") + if closeStart < 0 { + t.Fatal("Windows uninstall close region is missing") + } + closeRegion := windowsSource[closeStart:] + serviceRelease := strings.Index(closeRegion, "if t.releaseServiceMutex != nil") + packageRelease := strings.Index(closeRegion, "if t.releasePackageMutex != nil") + trustRelease := strings.Index(closeRegion, "if t.releaseTrustLease != nil") + if serviceRelease < 0 || packageRelease <= serviceRelease || trustRelease <= packageRelease { + t.Fatal("Windows uninstall no longer releases Service -> Package -> Trust") + } + + commandSourceBytes, err := os.ReadFile("install.go") + if err != nil { + t.Fatal(err) + } + commandSource := string(commandSourceBytes) + for _, fragment := range []string{ + "SourceRevision", + "LocalTestCertificatePath", + "ExpectedLocalTestCertificateSHA256", + "ExpectedLocalTestPackageLockSHA256", + } { + if !strings.Contains(commandSource, fragment) { + t.Fatalf("public uninstall dispatch lost %q", fragment) + } + } +} + +func TestNativePackageSettledLocalTestUninstallShortCircuitSourceContract(t *testing.T) { + t.Parallel() + windowsSourceBytes, err := os.ReadFile("native_package_uninstall_windows.go") + if err != nil { + t.Fatal(err) + } + windowsSource := string(windowsSourceBytes) + prepareStart := strings.Index(windowsSource, + "func (t *windowsNativePackageUninstallTransaction) prepareLocalTestTrustUninstall(") + prepareEnd := strings.Index(windowsSource, + "func (t *windowsNativePackageUninstallTransaction) InspectService(") + if prepareStart < 0 || prepareEnd <= prepareStart { + t.Fatal("local-test uninstall preparation source region is missing or malformed") + } + prepare := windowsSource[prepareStart:prepareEnd] + for _, fragment := range []string{ + "nativePackageProductionUninstallLocalTrustAdmission(states)", + "proveNativePackageSettledLocalTestTopologyAbsentReadOnly(", + `&nativePackageUninstallAlreadySettledError{state: "absent"}`, + `&nativePackageUninstallAlreadySettledError{state: "cleared"}`, + "nativePackageLocalTestUninstallMayMutateTopology(current.state)", + } { + if !strings.Contains(prepare, fragment) { + t.Fatalf("local-test uninstall settled fence lost %q", fragment) + } + } + if strings.Contains(prepare, "proveNativePackageLocalTestTopologyAbsent(") { + t.Fatal("settled local-test uninstall still invokes mutation-capable topology recovery") + } + + runnerSourceBytes, err := os.ReadFile("native_package_uninstall.go") + if err != nil { + t.Fatal(err) + } + runnerSource := string(runnerSourceBytes) + runnerStart := strings.Index(runnerSource, "func runNativePackageUninstallTransaction(") + runnerEnd := strings.Index(runnerSource, "type nativePackageUninstallRebootRequiredError") + if runnerStart < 0 || runnerEnd <= runnerStart { + t.Fatal("native uninstall runner source region is missing or malformed") + } + runner := runnerSource[runnerStart:runnerEnd] + settledCheck := strings.Index(runner, + "errors.As(err, &alreadySettled)") + inspect := strings.Index(runner, "transaction.InspectService(ctx)") + stop := strings.Index(runner, "transaction.StopService(ctx, snapshot)") + remove := strings.Index(runner, "transaction.RemoveDriver(ctx)") + if settledCheck < 0 || inspect <= settledCheck || stop <= inspect || remove <= stop { + t.Fatal("settled local-test uninstall no longer short-circuits before Inspect/STOP/remove") + } +} diff --git a/internal/cmd/native_package_uninstall_test.go b/internal/cmd/native_package_uninstall_test.go index 0c26c7ec..ca4a658b 100644 --- a/internal/cmd/native_package_uninstall_test.go +++ b/internal/cmd/native_package_uninstall_test.go @@ -13,18 +13,23 @@ import ( ) type fakeNativePackageUninstallTransaction struct { - events []string - fail string - closeErr error - restoreErr error - removeResult nativePackageRemoveResult - snapshot nativePackageUninstallServiceSnapshot - cancelAt string - cancel context.CancelFunc - restoreHadDeadline bool - cleanupHadDeadline bool - unsafeStop bool - cleanupReboot bool + events []string + fail string + preflightErr error + closeErr error + restoreErr error + removeResult nativePackageRemoveResult + snapshot nativePackageUninstallServiceSnapshot + topologyGeneration string + topologyMutations int + trustMutations int + cancelAt string + cancel context.CancelFunc + restoreHadDeadline bool + cleanupHadDeadline bool + finalizeHadDeadline bool + unsafeStop bool + cleanupReboot bool } func (f *fakeNativePackageUninstallTransaction) event(name string) error { @@ -38,16 +43,29 @@ func (f *fakeNativePackageUninstallTransaction) event(name string) error { return nil } +func (f *fakeNativePackageUninstallTransaction) LockTrust(context.Context) error { + return f.event("trust-lock") +} + func (f *fakeNativePackageUninstallTransaction) LockPackage(context.Context) error { return f.event("package-lock") } +func (f *fakeNativePackageUninstallTransaction) FinalizeTrust(ctx context.Context) error { + _, f.finalizeHadDeadline = ctx.Deadline() + f.trustMutations++ + return f.event("trust-finalize") +} + func (f *fakeNativePackageUninstallTransaction) LockService(context.Context) error { return f.event("service-lock") } func (f *fakeNativePackageUninstallTransaction) Preflight(context.Context) error { - return f.event("preflight") + if err := f.event("preflight"); err != nil { + return err + } + return f.preflightErr } func (f *fakeNativePackageUninstallTransaction) InspectService(context.Context) (nativePackageUninstallServiceSnapshot, error) { @@ -60,6 +78,8 @@ func (f *fakeNativePackageUninstallTransaction) StopService( if snapshot != f.snapshot { return errors.New("service snapshot changed") } + f.topologyMutations++ + f.topologyGeneration = "stopped" err := f.event("stop") if err != nil && f.unsafeStop { return &nativePackageUninstallUnsafeRestoreError{cause: err} @@ -68,6 +88,8 @@ func (f *fakeNativePackageUninstallTransaction) StopService( } func (f *fakeNativePackageUninstallTransaction) RemoveDriver(context.Context) (nativePackageRemoveResult, error) { + f.topologyMutations++ + f.topologyGeneration = "removed" return f.removeResult, f.event("remove") } @@ -77,6 +99,8 @@ func (f *fakeNativePackageUninstallTransaction) Cleanup( if snapshot != f.snapshot { return false, errors.New("service snapshot changed") } + f.topologyMutations++ + f.topologyGeneration = "cleaned" _, f.cleanupHadDeadline = ctx.Deadline() return f.cleanupReboot, f.event("cleanup") } @@ -87,6 +111,8 @@ func (f *fakeNativePackageUninstallTransaction) RestoreService( if snapshot != f.snapshot { return errors.New("service snapshot changed") } + f.topologyMutations++ + f.topologyGeneration = "restored" f.events = append(f.events, "restore") _, f.restoreHadDeadline = ctx.Deadline() return f.restoreErr @@ -108,8 +134,8 @@ func TestNativePackageUninstallUsesFixedLockAndCommitOrder(t *testing.T) { t.Fatalf("run uninstall: %v", err) } want := []string{ - "package-lock", "service-lock", "preflight", "inspect", - "stop", "remove", "cleanup", "close", + "trust-lock", "package-lock", "service-lock", "preflight", "inspect", + "stop", "remove", "cleanup", "trust-finalize", "close", } if !reflect.DeepEqual(fake.events, want) { t.Fatalf("events=%v want=%v", fake.events, want) @@ -117,12 +143,102 @@ func TestNativePackageUninstallUsesFixedLockAndCommitOrder(t *testing.T) { if !fake.cleanupHadDeadline { t.Fatal("committed driver removal cleanup did not receive a bounded reconciliation context") } + if !fake.finalizeHadDeadline { + t.Fatal("local-test trust finalization did not receive a bounded reconciliation context") + } +} + +func TestNativePackageLocalTestUninstallTopologyAuthorityMatrix(t *testing.T) { + t.Parallel() + for _, test := range []struct { + state string + want bool + }{ + {state: "preparing", want: false}, + {state: "pending", want: true}, + {state: "owned", want: true}, + {state: "uninstalling", want: true}, + {state: "cleared", want: false}, + {state: "absent", want: false}, + {state: "unknown", want: false}, + } { + test := test + t.Run(test.state, func(t *testing.T) { + t.Parallel() + if got := nativePackageLocalTestUninstallMayMutateTopology(test.state); got != test.want { + t.Fatalf("state=%q mayMutate=%v want=%v", test.state, got, test.want) + } + }) + } +} + +func TestNativePackageProductionUninstallLocalTrustAdmission(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + states []string + wantErr bool + }{ + {name: "absent"}, + {name: "cleared", states: []string{"cleared"}}, + {name: "preparing", states: []string{"preparing"}, wantErr: true}, + {name: "pending", states: []string{"pending"}, wantErr: true}, + {name: "owned", states: []string{"owned"}, wantErr: true}, + {name: "uninstalling", states: []string{"uninstalling"}, wantErr: true}, + {name: "multiple", states: []string{"cleared", "owned"}, wantErr: true}, + {name: "unknown", states: []string{"future"}, wantErr: true}, + } { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + err := nativePackageProductionUninstallLocalTrustAdmission(test.states) + if (err != nil) != test.wantErr { + t.Fatalf("states=%v error=%v wantErr=%v", test.states, err, test.wantErr) + } + }) + } +} + +func TestNativePackageSettledLocalTestUninstallCannotRemoveProductionSuccessor(t *testing.T) { + t.Parallel() + for _, state := range []string{"absent", "cleared"} { + state := state + t.Run(state, func(t *testing.T) { + t.Parallel() + // Model local owned -> uninstall -> cleared -> production install, + // followed by a replay of the now-stale source-bound local request. + const productionGeneration = "production-successor" + fake := &fakeNativePackageUninstallTransaction{ + preflightErr: &nativePackageUninstallAlreadySettledError{state: state}, + topologyGeneration: productionGeneration, + snapshot: nativePackageUninstallServiceSnapshot{exists: true, wasRunning: true}, + } + if err := runNativePackageUninstallTransaction( + context.Background(), nativePackageTestLogger(), fake, + ); err != nil { + t.Fatalf("settled stale local uninstall: %v", err) + } + wantEvents := []string{ + "trust-lock", "package-lock", "service-lock", "preflight", "close", + } + if !reflect.DeepEqual(fake.events, wantEvents) { + t.Fatalf("stale local uninstall crossed topology boundary: events=%v want=%v", fake.events, wantEvents) + } + if fake.topologyMutations != 0 || fake.trustMutations != 0 { + t.Fatalf("stale local uninstall mutated successor: topology=%d trust=%d", + fake.topologyMutations, fake.trustMutations) + } + if fake.topologyGeneration != productionGeneration { + t.Fatalf("production topology changed to %q", fake.topologyGeneration) + } + }) + } } func TestNativePackageUninstallFailureMatrix(t *testing.T) { t.Parallel() for _, fail := range []string{ - "package-lock", "service-lock", "preflight", "inspect", "stop", "remove", "cleanup", + "trust-lock", "package-lock", "service-lock", "preflight", "inspect", "stop", "remove", "cleanup", "trust-finalize", } { fail := fail t.Run(fail, func(t *testing.T) { @@ -164,11 +280,12 @@ func TestNativePackageUninstallCancellationBoundaries(t *testing.T) { wantEvents []string wantRestore bool }{ - {cancelAt: "package-lock", wantEvents: []string{"package-lock", "close"}}, - {cancelAt: "service-lock", wantEvents: []string{"package-lock", "service-lock", "close"}}, - {cancelAt: "preflight", wantEvents: []string{"package-lock", "service-lock", "preflight", "close"}}, - {cancelAt: "inspect", wantEvents: []string{"package-lock", "service-lock", "preflight", "inspect", "close"}}, - {cancelAt: "stop", wantEvents: []string{"package-lock", "service-lock", "preflight", "inspect", "stop", "restore", "close"}, wantRestore: true}, + {cancelAt: "trust-lock", wantEvents: []string{"trust-lock", "close"}}, + {cancelAt: "package-lock", wantEvents: []string{"trust-lock", "package-lock", "close"}}, + {cancelAt: "service-lock", wantEvents: []string{"trust-lock", "package-lock", "service-lock", "close"}}, + {cancelAt: "preflight", wantEvents: []string{"trust-lock", "package-lock", "service-lock", "preflight", "close"}}, + {cancelAt: "inspect", wantEvents: []string{"trust-lock", "package-lock", "service-lock", "preflight", "inspect", "close"}}, + {cancelAt: "stop", wantEvents: []string{"trust-lock", "package-lock", "service-lock", "preflight", "inspect", "stop", "restore", "close"}, wantRestore: true}, } for _, test := range cases { test := test @@ -276,7 +393,7 @@ func TestNativePackageUninstallRebootSuccessCleansBefore3010(t *testing.T) { t.Fatalf("error=%v exitCoder=%T", err, exitCoder) } want := []string{ - "package-lock", "service-lock", "preflight", "inspect", + "trust-lock", "package-lock", "service-lock", "preflight", "inspect", "stop", "remove", "cleanup", "close", } if !reflect.DeepEqual(fake.events, want) { @@ -497,6 +614,63 @@ func TestNativePackageUninstallRequestFailsClosed(t *testing.T) { } } +func TestNativePackageLocalTestUninstallRequestIsAllOrNothing(t *testing.T) { + t.Parallel() + base := nativePackageUninstallRequest{ + driverHelper: `C:\bundle\ViiperUdeCtl.exe`, + expectedHelperSHA256: strings.Repeat("a", 64), + targetUserSID: "S-1-5-21-1-2-3-1001", + sourceRevision: strings.Repeat("b", 40), + localTestCertificatePath: `C:\bundle\ViiperUdeTest.cer`, + expectedLocalTestCertificateSHA256: strings.Repeat("c", 64), + expectedLocalTestPackageLockSHA256: strings.Repeat("d", 64), + } + if err := base.validate(); err != nil { + t.Fatalf("valid local-test request: %v", err) + } + if !base.localTestTrustRequested() { + t.Fatal("complete local-test identity was not detected") + } + production := base + production.sourceRevision = "" + production.localTestCertificatePath = "" + production.expectedLocalTestCertificateSHA256 = "" + production.expectedLocalTestPackageLockSHA256 = "" + if err := production.validate(); err != nil { + t.Fatalf("valid production request: %v", err) + } + if production.localTestTrustRequested() { + t.Fatal("production request was treated as local-test") + } + cases := map[string]func(*nativePackageUninstallRequest){ + "missing revision": func(r *nativePackageUninstallRequest) { r.sourceRevision = "" }, + "bad revision": func(r *nativePackageUninstallRequest) { r.sourceRevision = "abc" }, + "missing certificate": func(r *nativePackageUninstallRequest) { r.localTestCertificatePath = "" }, + "relative certificate": func(r *nativePackageUninstallRequest) { r.localTestCertificatePath = "ViiperUdeTest.cer" }, + "wrong certificate": func(r *nativePackageUninstallRequest) { r.localTestCertificatePath = `C:\bundle\other.cer` }, + "missing certificate hash": func(r *nativePackageUninstallRequest) { + r.expectedLocalTestCertificateSHA256 = "" + }, + "bad package lock hash": func(r *nativePackageUninstallRequest) { + r.expectedLocalTestPackageLockSHA256 = strings.Repeat("z", 64) + }, + "certificate NUL": func(r *nativePackageUninstallRequest) { + r.localTestCertificatePath += "\x00evil" + }, + } + for name, mutate := range cases { + name, mutate := name, mutate + t.Run(name, func(t *testing.T) { + t.Parallel() + request := base + mutate(&request) + if err := request.validate(); err == nil { + t.Fatal("incomplete or malformed local-test identity accepted") + } + }) + } +} + func TestNativePackageUninstallNilTransaction(t *testing.T) { t.Parallel() err := runNativePackageUninstallTransaction(context.Background(), nativePackageTestLogger(), nil) diff --git a/internal/cmd/native_package_uninstall_windows.go b/internal/cmd/native_package_uninstall_windows.go index 3bf8b5c8..42a6d05c 100644 --- a/internal/cmd/native_package_uninstall_windows.go +++ b/internal/cmd/native_package_uninstall_windows.go @@ -74,11 +74,22 @@ type windowsNativePackageUninstallTransaction struct { logger *slog.Logger request nativePackageUninstallRequest - releasePackageMutex func() - releaseServiceMutex func() - helperHandles []windows.Handle - managedDirectories []windows.Handle - helperHandle windows.Handle + releaseTrustLease func() error + trustLeaseDirectories []windows.Handle + releasePackageMutex func() + releaseServiceMutex func() + helperHandles []windows.Handle + managedDirectories []windows.Handle + helperHandle windows.Handle + certificateHandle windows.Handle + certificateDER []byte + trustPaths nativePackageLocalTestTrustPaths + trustRecord nativePackageLocalTestTrustOwnership + trustRecordBytes []byte + trustState string + trustRootStore windows.Handle + trustPublisherStore windows.Handle + trustCutpoint func(string) error userSID string manager nativeSCM @@ -112,7 +123,35 @@ func remainingNativePackageUninstallBudget(ctx context.Context) (time.Duration, return remaining, nil } +func (t *windowsNativePackageUninstallTransaction) LockTrust(ctx context.Context) error { + if err := initializeNativePackageRecoveryTrustLease(); err != nil { + return fmt.Errorf("initialize protected local-test trust lease: %w", err) + } + budget, err := remainingNativePackageUninstallBudget(ctx) + if err != nil { + return err + } + deadline := time.Now().Add(budget) + if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) { + deadline = contextDeadline + } + lease, directories, err := acquireNativePackageRecoveryTrustLease(ctx, deadline) + if err != nil { + return err + } + t.trustLeaseDirectories = directories + t.releaseTrustLease = func() error { + err := releaseNativePackageRecoveryTrustLease(lease, t.trustLeaseDirectories) + t.trustLeaseDirectories = nil + return err + } + return nil +} + func (t *windowsNativePackageUninstallTransaction) LockPackage(ctx context.Context) error { + if t.releaseTrustLease == nil { + return errors.New("local-test trust lease must be held before the native package mutex") + } budget, err := remainingNativePackageUninstallBudget(ctx) if err != nil { return err @@ -142,8 +181,8 @@ func (t *windowsNativePackageUninstallTransaction) LockService(ctx context.Conte } func (t *windowsNativePackageUninstallTransaction) Preflight(ctx context.Context) error { - if t.releasePackageMutex == nil || t.releaseServiceMutex == nil { - return errors.New("native package uninstall mutex order is incomplete") + if t.releaseTrustLease == nil || t.releasePackageMutex == nil || t.releaseServiceMutex == nil { + return errors.New("native package uninstall Trust -> Package -> Service lock order is incomplete") } if err := ctx.Err(); err != nil { return err @@ -153,9 +192,6 @@ func (t *windowsNativePackageUninstallTransaction) Preflight(ctx context.Context return fmt.Errorf("resolve exact native broker credential owner: %w", err) } t.userSID = userSID - if err := reconcileNativeBrokerJournalBeforeAdmission(ctx, t.logger, t.userSID); err != nil { - return fmt.Errorf("reconcile interrupted native broker transaction before uninstall: %w", err) - } directoryHandles, err := lockNativePackageDirectoryChain(filepath.Dir(t.request.driverHelper)) if err != nil { @@ -178,6 +214,241 @@ func (t *windowsNativePackageUninstallTransaction) Preflight(ctx context.Context if err := requireNativePackagePE(helper); err != nil { return fmt.Errorf("validate packaged driver helper image: %w", err) } + if t.request.localTestTrustRequested() { + certificateDirectories, err := lockNativePackageDirectoryChain( + filepath.Dir(t.request.localTestCertificatePath), + ) + if err != nil { + return fmt.Errorf("lock source-bound local-test certificate directory chain: %w", err) + } + t.helperHandles = append(t.helperHandles, certificateDirectories...) + t.certificateHandle, err = lockNativePackageInput(t.request.localTestCertificatePath) + if err != nil { + return fmt.Errorf("lock source-bound local-test certificate: %w", err) + } + certificateHash, err := hashNativePackageHandle(t.certificateHandle) + if err != nil { + return fmt.Errorf("hash source-bound local-test certificate: %w", err) + } + if !strings.EqualFold(certificateHash, t.request.expectedLocalTestCertificateSHA256) { + return fmt.Errorf("local-test certificate SHA-256=%s expected=%s", + certificateHash, t.request.expectedLocalTestCertificateSHA256) + } + t.certificateDER, err = readNativePackageRecoveryFile( + t.certificateHandle, nativePackageRecoveryMaximumCertificateBytes, + ) + if err != nil { + return fmt.Errorf("read source-bound local-test certificate: %w", err) + } + } + if err := t.prepareLocalTestTrustUninstall(ctx); err != nil { + return fmt.Errorf("prepare local-test trust uninstall transaction: %w", err) + } + // Broker-journal reconciliation can stop, restore, or replace SCM/image + // state. Arm the exact local-test uninstalling record first so every process + // cut before or during that reconciliation remains source-bound and blocks a + // successor Install. + if err := reconcileNativeBrokerJournalBeforeAdmission(ctx, t.logger, t.userSID); err != nil { + return fmt.Errorf("reconcile interrupted native broker transaction after trust admission: %w", err) + } + return nil +} + +func nativePackageLocalTestTrustRecordMatchesUninstall( + record nativePackageLocalTestTrustOwnership, + request nativePackageUninstallRequest, +) bool { + return record.Schema == nativePackageLocalTestTrustOwnershipSchema && + record.SourceRevision == request.sourceRevision && + record.CertificateSHA256 == request.expectedLocalTestCertificateSHA256 && + record.PackageLockSHA256 == request.expectedLocalTestPackageLockSHA256 +} + +func (t *windowsNativePackageUninstallTransaction) trustCut(name string) error { + if t.trustCutpoint == nil { + return nil + } + return t.trustCutpoint(name) +} + +func (t *windowsNativePackageUninstallTransaction) openLocalTestTrustStores() error { + root, err := openNativePackageRecoveryCertificateStore("Root") + if err != nil { + return fmt.Errorf("open LocalMachine Root for local-test uninstall: %w", err) + } + publisher, err := openNativePackageRecoveryCertificateStore("TrustedPublisher") + if err != nil { + windows.CertCloseStore(root, 0) //nolint:errcheck + return fmt.Errorf("open LocalMachine TrustedPublisher for local-test uninstall: %w", err) + } + t.trustRootStore = root + t.trustPublisherStore = publisher + return nil +} + +func (t *windowsNativePackageUninstallTransaction) inspectLocalTestTrustCounts() ( + nativePackageRecoveryTrustCounts, + error, +) { + if t.trustRootStore == 0 || t.trustPublisherStore == 0 || len(t.certificateDER) == 0 { + return nativePackageRecoveryTrustCounts{}, errors.New( + "local-test trust stores or source-bound certificate are unavailable", + ) + } + counts, err := inspectNativePackageLocalTestTrust( + t.trustRootStore, t.trustPublisherStore, t.certificateDER, + ) + if err != nil { + return nativePackageRecoveryTrustCounts{}, err + } + if counts.root < 0 || counts.root > 1 || + counts.trustedPublisher < 0 || counts.trustedPublisher > 1 { + return nativePackageRecoveryTrustCounts{}, fmt.Errorf( + "local-test trust must contain at most one exact certificate per store; observed Root=%d TrustedPublisher=%d", + counts.root, counts.trustedPublisher, + ) + } + return counts, nil +} + +func (t *windowsNativePackageUninstallTransaction) prepareLocalTestTrustUninstall( + ctx context.Context, +) error { + if t.releaseTrustLease == nil || t.releasePackageMutex == nil || + t.releaseServiceMutex == nil { + return errors.New("local-test trust uninstall requires Trust -> Package -> Service locks") + } + if err := ctx.Err(); err != nil { + return err + } + if err := requireNativePackageTrustRecoveryClear(); err != nil { + return fmt.Errorf("revalidate failed-install trust recovery admission: %w", err) + } + paths, err := resolveNativePackageLocalTestTrustPaths() + if err != nil { + return err + } + t.trustPaths = paths + type observedRecord struct { + state string + path string + record nativePackageLocalTestTrustOwnership + bytes []byte + } + var observed []observedRecord + for _, candidate := range []struct { + state string + path string + }{ + {state: "preparing", path: paths.preparing}, + {state: "pending", path: paths.pending}, + {state: "owned", path: paths.owned}, + {state: "uninstalling", path: paths.uninstalling}, + {state: "cleared", path: paths.cleared}, + } { + record, contents, exists, readErr := readNativePackageLocalTestTrustRecord(candidate.path) + if readErr != nil { + return fmt.Errorf("read local-test trust %s record: %w", candidate.state, readErr) + } + if exists { + observed = append(observed, observedRecord{ + state: candidate.state, path: candidate.path, + record: record, bytes: contents, + }) + } + } + if len(observed) > 1 { + return errors.New("multiple local-test trust ownership states exist") + } + if !t.request.localTestTrustRequested() { + states := make([]string, len(observed)) + for index := range observed { + states[index] = observed[index].state + } + if err := nativePackageProductionUninstallLocalTrustAdmission(states); err != nil { + return err + } + return nil + } + if t.certificateHandle == 0 || len(t.certificateDER) == 0 { + return errors.New("source-bound local-test certificate was not locked before trust admission") + } + if err := t.openLocalTestTrustStores(); err != nil { + return err + } + counts, err := t.inspectLocalTestTrustCounts() + if err != nil { + return err + } + if len(observed) == 0 { + if counts.root != 0 || counts.trustedPublisher != 0 { + return fmt.Errorf( + "exact local-test certificate exists without durable ownership; refusing cleanup (Root=%d TrustedPublisher=%d)", + counts.root, counts.trustedPublisher, + ) + } + if err := proveNativePackageSettledLocalTestTopologyAbsentReadOnly( + ctx, t.request.driverHelper, t.request.targetUserSID, + ); err != nil { + return fmt.Errorf( + "recordless local-test uninstall cannot settle until exact topology absence is proven: %w", + err, + ) + } + return &nativePackageUninstallAlreadySettledError{state: "absent"} + } + current := observed[0] + if !nativePackageLocalTestTrustRecordMatchesUninstall(current.record, t.request) { + return errors.New("local-test trust ownership belongs to a different source-bound package") + } + t.trustRecord = current.record + t.trustRecordBytes = append([]byte(nil), current.bytes...) + t.trustState = current.state + if current.state == "preparing" { + return errors.New( + "local-test trust preparation is incomplete; rerun exact Install recovery before Uninstall", + ) + } + if current.state == "cleared" { + if counts.root != current.record.BaselineRoot || + counts.trustedPublisher != current.record.BaselineTrustedPublisher { + return fmt.Errorf( + "cleared local-test trust no longer matches its exact baseline (Root=%d/%d TrustedPublisher=%d/%d)", + counts.root, current.record.BaselineRoot, + counts.trustedPublisher, current.record.BaselineTrustedPublisher, + ) + } + if err := proveNativePackageSettledLocalTestTopologyAbsentReadOnly( + ctx, t.request.driverHelper, t.request.targetUserSID, + ); err != nil { + return fmt.Errorf( + "cleared local-test uninstall cannot settle until exact topology absence is proven: %w", + err, + ) + } + return &nativePackageUninstallAlreadySettledError{state: "cleared"} + } + if !nativePackageLocalTestUninstallMayMutateTopology(current.state) { + return errors.New("unknown local-test trust ownership state") + } + switch current.state { + case "pending", "owned": + if err := transitionNativePackageLocalTestTrustRecord( + current.path, paths.uninstalling, t.trustRecordBytes, + ); err != nil { + return fmt.Errorf("publish local-test trust uninstall authority: %w", err) + } + t.trustState = "uninstalling" + if err := t.trustCut("uninstalling-published"); err != nil { + return err + } + case "uninstalling": + // A prior process cut may have occurred at any topology-removal or + // certificate-baseline step. All operations below are idempotent while + // this exact state remains authoritative. + default: + return errors.New("unknown local-test trust ownership state") + } return nil } @@ -864,6 +1135,88 @@ func (t *windowsNativePackageUninstallTransaction) Cleanup( return cleanupRebootRequired, errors.Join(cleanupErrors...) } +func (t *windowsNativePackageUninstallTransaction) FinalizeTrust(ctx context.Context) error { + if !t.request.localTestTrustRequested() { + return nil + } + if t.releaseTrustLease == nil || t.releasePackageMutex == nil || + t.releaseServiceMutex == nil { + return errors.New("local-test trust finalization lost Trust -> Package -> Service locks") + } + if err := ctx.Err(); err != nil { + return err + } + counts, err := t.inspectLocalTestTrustCounts() + if err != nil { + return err + } + switch t.trustState { + case "absent": + if counts.root != 0 || counts.trustedPublisher != 0 { + return fmt.Errorf( + "unowned local-test trust appeared during uninstall (Root=%d TrustedPublisher=%d)", + counts.root, counts.trustedPublisher, + ) + } + return nil + case "cleared": + if counts.root != t.trustRecord.BaselineRoot || + counts.trustedPublisher != t.trustRecord.BaselineTrustedPublisher { + return errors.New("cleared local-test trust changed from its exact baseline") + } + return nil + case "uninstalling": + // Continue below. The durable state remains uninstalling on every error + // and therefore blocks a successor Install from reusing this trust. + default: + return fmt.Errorf("local-test trust is not authorized for finalization from state %q", t.trustState) + } + if t.helperHandle == 0 { + return errors.New("packaged driver helper was not retained through trust finalization") + } + helperHash, err := hashNativePackageHandle(t.helperHandle) + if err != nil { + return fmt.Errorf("rehash packaged driver helper before topology proof: %w", err) + } + if !strings.EqualFold(helperHash, t.request.expectedHelperSHA256) { + return errors.New("packaged driver helper changed before trust finalization") + } + if err := proveNativePackageLocalTestTopologyAbsent( + ctx, t.logger, t.request.driverHelper, t.request.targetUserSID, + ); err != nil { + return fmt.Errorf("prove exact native topology absent before restoring trust baseline: %w", err) + } + if err := restoreNativePackageLocalTestTrustStores( + t.trustRootStore, + t.trustPublisherStore, + t.certificateDER, + t.trustRecord, + t.trustCut, + ); err != nil { + return fmt.Errorf("restore exact local-test certificate baselines: %w", err) + } + if err := transitionNativePackageLocalTestTrustRecord( + t.trustPaths.uninstalling, + t.trustPaths.cleared, + t.trustRecordBytes, + ); err != nil { + return fmt.Errorf("publish cleared local-test trust settlement: %w", err) + } + t.trustState = "cleared" + if err := t.trustCut("cleared-published"); err != nil { + return err + } + counts, err = t.inspectLocalTestTrustCounts() + if err != nil { + return err + } + if counts.root != t.trustRecord.BaselineRoot || + counts.trustedPublisher != t.trustRecord.BaselineTrustedPublisher { + return errors.New("local-test trust baseline changed after cleared settlement") + } + return nil +} + func deleteNativePackageUninstallFileHandle(handle windows.Handle) error { disposition := struct{ DeleteFile byte }{DeleteFile: 1} result, _, callErr := setNativeFileInformationByHandle.Call( @@ -1121,6 +1474,24 @@ func (t *windowsNativePackageUninstallTransaction) Close() error { } closeNativePackageUninstallHandles(t.managedDirectories) t.managedDirectories = nil + if t.trustRootStore != 0 { + if err := windows.CertCloseStore(t.trustRootStore, 0); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close LocalMachine Root trust store: %w", err)) + } + t.trustRootStore = 0 + } + if t.trustPublisherStore != 0 { + if err := windows.CertCloseStore(t.trustPublisherStore, 0); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close LocalMachine TrustedPublisher trust store: %w", err)) + } + t.trustPublisherStore = 0 + } + if t.certificateHandle != 0 { + if err := windows.CloseHandle(t.certificateHandle); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close source-bound local-test certificate: %w", err)) + } + t.certificateHandle = 0 + } if t.helperHandle != 0 { if err := windows.CloseHandle(t.helperHandle); err != nil { closeErrors = append(closeErrors, fmt.Errorf("close packaged driver helper: %w", err)) @@ -1150,6 +1521,12 @@ func (t *windowsNativePackageUninstallTransaction) Close() error { t.releasePackageMutex() t.releasePackageMutex = nil } + if t.releaseTrustLease != nil { + if err := t.releaseTrustLease(); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("release local-test trust transaction: %w", err)) + } + t.releaseTrustLease = nil + } return errors.Join(closeErrors...) } diff --git a/internal/cmd/native_package_uninstall_windows_test.go b/internal/cmd/native_package_uninstall_windows_test.go index 256b831e..ca9b1e7f 100644 --- a/internal/cmd/native_package_uninstall_windows_test.go +++ b/internal/cmd/native_package_uninstall_windows_test.go @@ -295,3 +295,82 @@ func TestNativePackageUninstallCapturesLogCreatedBeforeStop(t *testing.T) { t.Fatalf("created exact log was not locked: %+v", transaction.ownedFiles) } } + +func TestNativePackageLocalTestUninstallIdentityBinding(t *testing.T) { + t.Parallel() + request := nativePackageUninstallRequest{ + sourceRevision: strings.Repeat("a", 40), + expectedLocalTestCertificateSHA256: strings.Repeat("b", 64), + expectedLocalTestPackageLockSHA256: strings.Repeat("c", 64), + } + record := nativePackageLocalTestTrustOwnership{ + Schema: nativePackageLocalTestTrustOwnershipSchema, + SourceRevision: request.sourceRevision, + CertificateSHA256: request.expectedLocalTestCertificateSHA256, + PackageLockSHA256: request.expectedLocalTestPackageLockSHA256, + BaselineRoot: 0, + BaselineTrustedPublisher: 0, + } + if !nativePackageLocalTestTrustRecordMatchesUninstall(record, request) { + t.Fatal("exact source/certificate/package-lock ownership did not match") + } + mutations := []func(*nativePackageLocalTestTrustOwnership){ + func(value *nativePackageLocalTestTrustOwnership) { value.SourceRevision = strings.Repeat("d", 40) }, + func(value *nativePackageLocalTestTrustOwnership) { value.CertificateSHA256 = strings.Repeat("e", 64) }, + func(value *nativePackageLocalTestTrustOwnership) { value.PackageLockSHA256 = strings.Repeat("f", 64) }, + } + for index, mutate := range mutations { + changed := record + mutate(&changed) + if nativePackageLocalTestTrustRecordMatchesUninstall(changed, request) { + t.Fatalf("mismatched ownership identity %d was admitted", index) + } + } +} + +func TestNativePackageLocalTestUninstallJournalTransitionsAreResumable(t *testing.T) { + requireNativeMutexAdministrator(t) + directory := t.TempDir() + pending := filepath.Join(directory, nativePackageLocalTrustPendingName) + uninstalling := filepath.Join(directory, nativePackageLocalTrustUninstallingName) + cleared := filepath.Join(directory, nativePackageLocalTrustClearedName) + record := nativePackageLocalTestTrustOwnership{ + Schema: nativePackageLocalTestTrustOwnershipSchema, + SourceRevision: strings.Repeat("a", 40), + CertificateSHA256: strings.Repeat("b", 64), + PackageLockSHA256: strings.Repeat("c", 64), + BaselineRoot: 0, + BaselineTrustedPublisher: 1, + } + contents, err := canonicalNativePackageLocalTestTrustOwnership(record) + if err != nil { + t.Fatal(err) + } + if err := publishNativePackageLocalTestTrustPreparing(pending, contents); err != nil { + t.Fatalf("publish pending fixture: %v", err) + } + if err := transitionNativePackageLocalTestTrustRecord( + pending, uninstalling, contents, + ); err != nil { + t.Fatalf("arm uninstall authority: %v", err) + } + observed, observedBytes, exists, err := readNativePackageLocalTestTrustRecord(uninstalling) + if err != nil || !exists || !nativePackageLocalTestTrustRecordMatchesUninstall( + observed, + nativePackageUninstallRequest{ + sourceRevision: record.SourceRevision, + expectedLocalTestCertificateSHA256: record.CertificateSHA256, + expectedLocalTestPackageLockSHA256: record.PackageLockSHA256, + }, + ) || string(observedBytes) != string(contents) { + t.Fatalf("uninstalling cut was not exactly resumable: exists=%v record=%+v error=%v", exists, observed, err) + } + if err := transitionNativePackageLocalTestTrustRecord( + uninstalling, cleared, contents, + ); err != nil { + t.Fatalf("settle cleared authority: %v", err) + } + if _, finalBytes, exists, err := readNativePackageLocalTestTrustRecord(cleared); err != nil || !exists || string(finalBytes) != string(contents) { + t.Fatalf("cleared cut was not exact: exists=%v error=%v", exists, err) + } +} diff --git a/internal/cmd/native_package_windows.go b/internal/cmd/native_package_windows.go index c297f5c2..a7ba455d 100644 --- a/internal/cmd/native_package_windows.go +++ b/internal/cmd/native_package_windows.go @@ -6,8 +6,10 @@ import ( "bytes" "context" "crypto/rand" + "crypto/sha1" // #nosec G505 -- used only to reject Windows thumbprint collisions. "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "fmt" "io" @@ -29,6 +31,11 @@ import ( const nativePackageMutexName = "VIIPER.NativePackage.Install.v1" const nativePackageTokenSDDL = "O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)" +const nativePackageLocalTestTrustCapabilitySchema = "viiper.native.local-test-trust-capability/v1" +const nativePackageLocalTestTrustCapabilityName = "local-test-trust-capability.json" +const nativePackageLocalTestTrustCapabilityMaximumBytes = 4096 +const nativePackageLocalTestTrustOwnershipSchema = "viiper.native.local-test-trust-ownership/v1" +const nativePackageLocalTrustClearedName = "local-test-trust-cleared-v1.json" var nativePackageDriverFiles = []string{ "ViiperUde.inf", "ViiperUde.sys", "ViiperUde.cat", @@ -41,6 +48,8 @@ type windowsNativePackageTransaction struct { releaseMutex func() releaseServiceMutex func() + releaseTrustLease func() error + trustLeaseDirectoryHandles []windows.Handle inputHandles []windows.Handle sourceHandle windows.Handle helperHandle windows.Handle @@ -52,6 +61,8 @@ type windowsNativePackageTransaction struct { driverBrokerHandoff bool driverHelperSettled bool driverCoordinationErr error + driverInstallProof nativePackageInstallProof + driverInstallProofPresent bool pendingBrokerOuterSettlement bool replayedBrokerRecovery bool @@ -89,6 +100,80 @@ type windowsNativePackageTransaction struct { brokerJournalProof nativeBrokerJournalProof brokerJournalCutpoint func(string) error closed bool + + localTestCertificateHandle windows.Handle + localTestCertificateDER []byte + localTestTrust *nativePackageLocalTestTrustState + localTestTrustCutpoint func(string) error +} + +type nativePackageLocalTestTrustCapability struct { + Schema string `json:"schema"` + Nonce string `json:"nonce"` + ParentPID uint32 `json:"parentPid"` + ParentCreationFileTime uint64 `json:"parentCreationFileTime"` + SourceRevision string `json:"sourceRevision"` + CertificatePath string `json:"certificatePath"` + CertificateSHA256 string `json:"certificateSha256"` + PackageLockSHA256 string `json:"packageLockSha256"` + TrustJournalSchema string `json:"trustJournalSchema"` + TrustJournalDirectory string `json:"trustJournalDirectory"` +} + +type nativePackageLocalTestTrustOwnership struct { + Schema string `json:"schema"` + SourceRevision string `json:"sourceRevision"` + CertificateSHA256 string `json:"certificateSha256"` + PackageLockSHA256 string `json:"packageLockSha256"` + BaselineRoot int `json:"baselineRoot"` + BaselineTrustedPublisher int `json:"baselineTrustedPublisher"` +} + +type nativePackageLocalTestTrustPaths struct { + directory string + preparing string + pending string + owned string + uninstalling string + cleared string +} + +type nativePackageLocalTestTrustState struct { + paths nativePackageLocalTestTrustPaths + record nativePackageLocalTestTrustOwnership + bytes []byte + certificateDER []byte + rootStore windows.Handle + publisherStore windows.Handle + state string + createdCurrent bool + resumed bool + alreadyOwned bool +} + +func executeNativePackageLocalTestTrustStep( + name string, + cutBefore bool, + operation func() error, + cutpoint func(string) error, +) error { + if operation == nil { + return errors.New("local-test trust durable step has no operation") + } + if cutBefore && cutpoint != nil { + if err := cutpoint(name); err != nil { + return err + } + } + if err := operation(); err != nil { + return err + } + if !cutBefore && cutpoint != nil { + if err := cutpoint(name); err != nil { + return err + } + } + return nil } func installNativePackage( @@ -232,145 +317,1539 @@ func commitNativePackageBroker( rollback: "not-needed", exitCode: 0, journal: transaction.brokerJournalProof, }, nil } - if !transaction.nestedMutationStarted { - return nativePackageBrokerPreflightFailure(err) + if !transaction.nestedMutationStarted { + return nativePackageBrokerPreflightFailure(err) + } + if transaction.nestedRollbackSucceeded { + return nativePackageBrokerCommitResult{ + changed: true, rollback: "succeeded", exitCode: 1, + journal: transaction.brokerJournalProof, + }, err + } + return nativePackageBrokerCommitResult{ + changed: true, rollback: "failed", exitCode: 3, + journal: transaction.brokerJournalProof, + }, err +} + +func (t *windowsNativePackageTransaction) Preflight(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + if t.nestedBrokerCommit { + return t.preflightNestedBrokerCommit() + } + mutexBudget := nativePackageTransactionTimeout + if deadline, ok := ctx.Deadline(); ok { + mutexBudget = time.Until(deadline) + if mutexBudget <= 0 { + return context.DeadlineExceeded + } + } + // Every outer install mode participates in the machine-global trust order. + // Production does not mutate local-test trust, but it must serialize ahead + // of Package and Service so it cannot become a successor underneath a + // pending recovery or ownership transaction. + if err := initializeNativePackageRecoveryTrustLease(); err != nil { + return fmt.Errorf("initialize protected package trust lease: %w", err) + } + trustDeadline := time.Now().Add(mutexBudget) + if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(trustDeadline) { + trustDeadline = contextDeadline + } + trustLease, trustDirectories, err := acquireNativePackageRecoveryTrustLease(ctx, trustDeadline) + if err != nil { + return fmt.Errorf("acquire protected package trust lease: %w", err) + } + t.trustLeaseDirectoryHandles = trustDirectories + t.releaseTrustLease = func() error { + err := releaseNativePackageRecoveryTrustLease( + trustLease, t.trustLeaseDirectoryHandles, + ) + t.trustLeaseDirectoryHandles = nil + return err + } + packageBudget := mutexBudget + if deadline, ok := ctx.Deadline(); ok { + packageBudget = time.Until(deadline) + if packageBudget <= 0 { + return context.DeadlineExceeded + } + } + release, err := acquireNamedNativePackageMutex(nativePackageMutexName, packageBudget) + if err != nil { + return err + } + t.releaseMutex = release + if err := requireNativePackageTrustRecoveryClear(); err != nil { + return fmt.Errorf("admit package install after failed-install trust recovery: %w", err) + } + if err := t.admitProductionLocalTestTrust(); err != nil { + return fmt.Errorf("admit production install after local-test trust lifecycle: %w", err) + } + if _, err := validateNativeInstallingUserSID(t.request.targetUserSID); err != nil { + return fmt.Errorf("validate target user SID: %w", err) + } + programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT) + if err != nil { + return fmt.Errorf("resolve Program Files known folder: %w", err) + } + t.programFiles = filepath.Clean(programFiles) + t.parent = filepath.Join(t.programFiles, "VIIPER") + t.destination = filepath.Join(t.parent, "viiper.exe") + if _, err := nativeServiceExecutableParent(t.programFiles, t.destination); err != nil { + return err + } + programFilesHandle, err := openNativePathWithoutReparse( + t.programFiles, windows.FILE_READ_ATTRIBUTES, true, + ) + if err != nil { + return fmt.Errorf("lock Program Files root: %w", err) + } + t.inputHandles = append(t.inputHandles, programFilesHandle) + inputs := []struct { + name string + directory string + }{ + {name: "broker source", directory: filepath.Dir(t.request.brokerSource)}, + {name: "driver helper", directory: filepath.Dir(t.request.driverHelper)}, + {name: "submission manifest", directory: filepath.Dir(t.request.submissionManifest)}, + {name: "signed driver package", directory: t.request.packageDirectory}, + } + if t.request.driverValidationMode == "local-test" { + inputs = append(inputs, struct { + name string + directory string + }{name: "local-test certificate", directory: filepath.Dir(t.request.localTestCertificatePath)}) + } + for _, input := range inputs { + handles, lockErr := lockNativePackageDirectoryChain(input.directory) + if lockErr != nil { + return fmt.Errorf("lock %s directory chain: %w", input.name, lockErr) + } + t.inputHandles = append(t.inputHandles, handles...) + } + + t.sourceHandle, err = t.lockAndVerifyInput( + t.request.brokerSource, t.request.expectedBrokerSHA256, true, + ) + if err != nil { + return fmt.Errorf("verify installer-bound VIIPER broker: %w", err) + } + if t.request.driverValidationMode == "local-test" { + if err := t.verifyLocalTestTrustCapability(); err != nil { + return fmt.Errorf("verify parent-bound local-test trust capability: %w", err) + } + t.localTestCertificateHandle, err = t.lockAndVerifyInput( + t.request.localTestCertificatePath, + t.request.expectedLocalTestCertificateSHA256, + false, + ) + if err != nil { + return fmt.Errorf("verify source-bound local-test certificate: %w", err) + } + t.localTestCertificateDER, err = readNativePackageRecoveryFile( + t.localTestCertificateHandle, nativePackageRecoveryMaximumCertificateBytes, + ) + if err != nil { + return fmt.Errorf("read source-bound local-test certificate: %w", err) + } + } + t.helperHandle, err = t.lockAndVerifyInput( + t.request.driverHelper, t.request.expectedHelperSHA256, true, + ) + if err != nil { + return fmt.Errorf("verify installer-bound driver helper: %w", err) + } + entries, err := os.ReadDir(t.request.packageDirectory) + if err != nil { + return fmt.Errorf("enumerate signed driver package: %w", err) + } + if len(entries) != len(nativePackageDriverFiles) { + return fmt.Errorf("signed runtime driver package must contain exactly INF, SYS, and CAT, found %d files", len(entries)) + } + for _, expected := range nativePackageDriverFiles { + matches := 0 + for _, entry := range entries { + if entry.Name() == expected && entry.Type().IsRegular() { + matches++ + } + } + if matches != 1 { + return fmt.Errorf("signed driver package must contain one case-exact regular %s", expected) + } + expectedHash := map[string]string{ + "ViiperUde.inf": t.request.expectedInfSHA256, + "ViiperUde.sys": t.request.expectedSysSHA256, + "ViiperUde.cat": t.request.expectedCatSHA256, + }[expected] + handle, lockErr := t.lockAndVerifyInput( + filepath.Join(t.request.packageDirectory, expected), expectedHash, false, + ) + if lockErr != nil { + return fmt.Errorf("verify installer-bound signed driver file %s: %w", expected, lockErr) + } + _ = handle + } + manifestHandle, err := t.lockAndVerifyInput( + t.request.submissionManifest, t.request.expectedManifestSHA256, false, + ) + if err != nil { + return fmt.Errorf("verify installer-bound driver manifest: %w", err) + } + _ = manifestHandle + + if attributes, attrErr := nativePathAttributes(t.parent); attrErr == nil { + if attributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 || + attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return errors.New("managed VIIPER directory is not a regular non-reparse directory") + } + parent, openErr := openNativePathWithoutReparse( + t.parent, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if openErr != nil { + return fmt.Errorf("open managed VIIPER directory: %w", openErr) + } + defer windows.CloseHandle(parent) //nolint:errcheck + if validateErr := validateNativeSecurityDescriptor(parent, nativeBrokerDirectorySDDL); validateErr != nil { + return fmt.Errorf("managed VIIPER directory is not installer-owned: %w", validateErr) + } + } else if !errors.Is(attrErr, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(attrErr, windows.ERROR_PATH_NOT_FOUND) { + return fmt.Errorf("inspect managed VIIPER directory: %w", attrErr) + } + return nil +} + +func (t *windowsNativePackageTransaction) verifyLocalTestTrustCapability() error { + capabilityPath := filepath.Clean(t.request.localTestTrustCapability) + if filepath.Base(capabilityPath) != nativePackageLocalTestTrustCapabilityName || + !strings.EqualFold(filepath.Dir(capabilityPath), filepath.Dir(t.request.brokerSource)) { + return errors.New("local-test trust capability is not the exact protected sibling of the staged broker") + } + handle, err := lockNativePackageInput(capabilityPath) + if err != nil { + return fmt.Errorf("lock local-test trust capability: %w", err) + } + keep := false + defer func() { + if !keep { + windows.CloseHandle(handle) //nolint:errcheck + } + }() + if err := validateNativeSecurityDescriptor( + handle, nativePackageRecoveryTrustLeaseFileSDDL, + ); err != nil { + return fmt.Errorf("validate local-test trust capability security: %w", err) + } + digest, err := hashNativePackageHandle(handle) + if err != nil { + return fmt.Errorf("hash local-test trust capability: %w", err) + } + if digest != t.request.expectedTrustCapabilitySHA256 { + return fmt.Errorf("local-test trust capability SHA-256=%s expected=%s", + digest, t.request.expectedTrustCapabilitySHA256) + } + contents, err := readNativePackageCapabilityHandle( + handle, nativePackageLocalTestTrustCapabilityMaximumBytes, + ) + if err != nil { + return fmt.Errorf("read local-test trust capability: %w", err) + } + capability := nativePackageLocalTestTrustCapability{} + if err := decodeCanonicalNativeBrokerJSON( + contents, &capability, nativePackageLocalTestTrustCapabilityMaximumBytes, + ); err != nil { + return fmt.Errorf("decode canonical local-test trust capability: %w", err) + } + paths, err := resolveNativePackageRecoveryMarkerPaths() + if err != nil { + return err + } + parentPID, parentCreationFileTime, err := nativePackageParentIdentity() + if err != nil { + return err + } + if err := validateNativePackageLocalTestTrustCapability( + capability, t.request, paths.directory, parentPID, parentCreationFileTime, + ); err != nil { + return err + } + t.inputHandles = append(t.inputHandles, handle) + keep = true + return nil +} + +func decodeCanonicalNativePackageLocalTestTrustOwnership( + contents []byte, +) (nativePackageLocalTestTrustOwnership, error) { + value := nativePackageLocalTestTrustOwnership{} + if len(contents) < 2 || len(contents) > nativePackageLocalTestTrustCapabilityMaximumBytes || + contents[len(contents)-1] != '\n' || bytes.IndexByte(contents[:len(contents)-1], '\n') >= 0 { + return value, errors.New("local-test trust ownership journal has invalid framing") + } + decoder := json.NewDecoder(bytes.NewReader(contents[:len(contents)-1])) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, fmt.Errorf("decode local-test trust ownership journal: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return value, errors.New("local-test trust ownership journal has trailing JSON") + } + canonical, err := json.Marshal(value) + if err != nil { + return value, err + } + canonical = append(canonical, '\n') + if !bytes.Equal(canonical, contents) { + return value, errors.New("local-test trust ownership journal is not canonical") + } + if value.Schema != nativePackageLocalTestTrustOwnershipSchema || + !nativePackageHexRevision.MatchString(value.SourceRevision) || + !nativePackageSHA256.MatchString(value.CertificateSHA256) || + !nativePackageSHA256.MatchString(value.PackageLockSHA256) || + value.BaselineRoot < 0 || value.BaselineRoot > 1 || + value.BaselineTrustedPublisher < 0 || value.BaselineTrustedPublisher > 1 { + return value, errors.New("local-test trust ownership journal schema or baselines are invalid") + } + return value, nil +} + +func validateNativePackageLocalTestTrustCapability( + capability nativePackageLocalTestTrustCapability, + request nativePackageRequest, + expectedJournalDirectory string, + parentPID uint32, + parentCreationFileTime uint64, +) error { + if capability.Schema != nativePackageLocalTestTrustCapabilitySchema || + len(capability.Nonce) != 32 || capability.Nonce != strings.ToLower(capability.Nonce) { + return errors.New("local-test trust capability schema or nonce is noncanonical") + } + if _, err := hex.DecodeString(capability.Nonce); err != nil { + return errors.New("local-test trust capability nonce is not 128-bit lowercase hexadecimal") + } + if capability.SourceRevision != request.sourceRevision || + !strings.EqualFold(filepath.Clean(capability.CertificatePath), filepath.Clean(request.localTestCertificatePath)) || + capability.CertificateSHA256 != request.expectedLocalTestCertificateSHA256 || + capability.PackageLockSHA256 != request.expectedLocalTestPackageLockSHA256 { + return errors.New("local-test trust capability does not bind the exact source, certificate path/hash, and package lock") + } + if capability.TrustJournalSchema != nativePackageLocalTestTrustOwnershipSchema || + !strings.EqualFold(filepath.Clean(capability.TrustJournalDirectory), expectedJournalDirectory) { + return errors.New("local-test trust capability does not bind the native fixed ownership journal") + } + if capability.ParentPID != parentPID || + capability.ParentCreationFileTime != parentCreationFileTime { + return errors.New("local-test trust capability was not issued by this broker process parent") + } + return nil +} + +func nativePackageParentIdentity() (uint32, uint64, error) { + parent := os.Getppid() + if parent <= 0 || uint64(parent) > uint64(^uint32(0)) { + return 0, 0, errors.New("native package parent process ID is invalid") + } + pid := uint32(parent) + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if err != nil { + return 0, 0, fmt.Errorf("open native package parent process %d: %w", pid, err) + } + defer windows.CloseHandle(handle) //nolint:errcheck + creation := windows.Filetime{} + exit := windows.Filetime{} + kernel := windows.Filetime{} + user := windows.Filetime{} + if err := windows.GetProcessTimes(handle, &creation, &exit, &kernel, &user); err != nil { + return 0, 0, fmt.Errorf("query native package parent creation time: %w", err) + } + creationFileTime := uint64(creation.HighDateTime)<<32 | uint64(creation.LowDateTime) + if creationFileTime == 0 { + return 0, 0, errors.New("native package parent creation time is zero") + } + return pid, creationFileTime, nil +} + +func readNativePackageCapabilityHandle(handle windows.Handle, maximum int) ([]byte, error) { + information := windows.ByHandleFileInformation{} + if err := windows.GetFileInformationByHandle(handle, &information); err != nil { + return nil, err + } + size := uint64(information.FileSizeHigh)<<32 | uint64(information.FileSizeLow) + if size == 0 || size > uint64(maximum) { + return nil, errors.New("local-test trust capability length is outside its exact bound") + } + if _, err := windows.SetFilePointer(handle, 0, nil, windows.FILE_BEGIN); err != nil { + return nil, err + } + contents := make([]byte, int(size)) + offset := 0 + for offset < len(contents) { + var read uint32 + if err := windows.ReadFile(handle, contents[offset:], &read, nil); err != nil { + return nil, err + } + if read == 0 { + return nil, io.ErrUnexpectedEOF + } + offset += int(read) + } + extra := []byte{0} + var read uint32 + if err := windows.ReadFile(handle, extra, &read, nil); err != nil { + return nil, err + } + if read != 0 { + return nil, errors.New("local-test trust capability changed length while locked") + } + if _, err := windows.SetFilePointer(handle, 0, nil, windows.FILE_BEGIN); err != nil { + return nil, err + } + return contents, nil +} + +// initializeNativePackageRecoveryTrustLease is the sole initializer for the +// fixed machine-wide trust lock. Both install and recovery call it before +// acquiring byte zero; PowerShell never creates, repairs, or owns this object. +func initializeNativePackageRecoveryTrustLease() error { + paths, err := resolveNativePackageRecoveryMarkerPaths() + if err != nil { + return err + } + parentHandles, err := lockNativePackageDirectoryChain(filepath.Dir(paths.directory)) + if err != nil { + return fmt.Errorf("lock fixed trust directory parent: %w", err) + } + defer closeNativePackageUninstallHandles(parentHandles) + directorySecurity, err := nativeSecurityAttributes( + nativePackageRecoveryTrustLeaseDirectorySDDL, + ) + if err != nil { + return err + } + directoryPointer, err := windows.UTF16PtrFromString(paths.directory) + if err != nil { + return err + } + if err := windows.CreateDirectory(directoryPointer, directorySecurity); err != nil && + !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + return fmt.Errorf("create fixed trust directory: %w", err) + } + directoryHandle, err := openNativePathWithoutReparse( + paths.directory, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if err != nil { + return fmt.Errorf("open fixed trust directory: %w", err) + } + defer windows.CloseHandle(directoryHandle) //nolint:errcheck + if err := validateNativeSecurityDescriptor( + directoryHandle, nativePackageRecoveryTrustLeaseDirectorySDDL, + ); err != nil { + return fmt.Errorf("validate fixed trust directory: %w", err) + } + + fileSecurity, err := nativeSecurityAttributes(nativePackageRecoveryTrustLeaseFileSDDL) + if err != nil { + return err + } + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return fmt.Errorf("generate fixed trust lease preparation identity: %w", err) + } + temporary := filepath.Join( + paths.directory, + nativePackageRecoveryTrustLeaseFileName+"."+hex.EncodeToString(nonce[:])+".preparing", + ) + if !strings.EqualFold(filepath.Dir(temporary), paths.directory) { + return errors.New("fixed trust lease preparation escaped its directory") + } + leasePointer, err := windows.UTF16PtrFromString(temporary) + if err != nil { + return err + } + lease, err := windows.CreateFile( + leasePointer, + windows.GENERIC_READ|windows.GENERIC_WRITE|windows.READ_CONTROL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, + fileSecurity, + windows.CREATE_NEW, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_WRITE_THROUGH| + windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err == nil { + removeTemporary := true + defer func() { + if removeTemporary { + deleteNativePackageFile(temporary) //nolint:errcheck + } + }() + marker := []byte{1} + var written uint32 + if writeErr := windows.WriteFile(lease, marker, &written, nil); writeErr != nil || written != 1 { + windows.CloseHandle(lease) //nolint:errcheck + if writeErr == nil { + writeErr = io.ErrShortWrite + } + return fmt.Errorf("initialize fixed trust lease marker: %w", writeErr) + } + if flushErr := windows.FlushFileBuffers(lease); flushErr != nil { + windows.CloseHandle(lease) //nolint:errcheck + return fmt.Errorf("flush fixed trust lease marker: %w", flushErr) + } + if closeErr := windows.CloseHandle(lease); closeErr != nil { + return fmt.Errorf("close initialized fixed trust lease: %w", closeErr) + } + prepublish, openErr := lockNativePackageInput(temporary) + if openErr != nil { + return fmt.Errorf("reopen fixed trust lease preparation: %w", openErr) + } + information := windows.ByHandleFileInformation{} + validateErr := windows.GetFileInformationByHandle(prepublish, &information) + if validateErr == nil && (information.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 || + information.FileSizeHigh != 0 || information.FileSizeLow != 1) { + validateErr = errors.New("fixed trust lease preparation is not an exact one-byte regular file") + } + if validateErr == nil { + validateErr = validateNativeFileLinkCount(information.NumberOfLinks) + } + if validateErr == nil { + validateErr = validateNativeSecurityDescriptor( + prepublish, nativePackageRecoveryTrustLeaseFileSDDL, + ) + } + var readback []byte + if validateErr == nil { + readback, validateErr = readNativePackageRecoveryFile(prepublish, 1) + } + closeErr := windows.CloseHandle(prepublish) + if validateErr != nil || closeErr != nil || !bytes.Equal(readback, []byte{1}) { + if validateErr == nil && closeErr == nil { + validateErr = errors.New("fixed trust lease preparation readback changed") + } + return errors.Join(validateErr, closeErr) + } + if moveErr := moveNativePackageFile(temporary, paths.lease, false); moveErr == nil { + removeTemporary = false + } else if errors.Is(moveErr, windows.ERROR_FILE_EXISTS) || + errors.Is(moveErr, windows.ERROR_ALREADY_EXISTS) { + if deleteErr := deleteNativePackageFile(temporary); deleteErr != nil { + return fmt.Errorf("discard losing fixed trust lease preparation: %w", deleteErr) + } + removeTemporary = false + } else { + return fmt.Errorf("publish fixed trust lease: %w", moveErr) + } + } else if !errors.Is(err, windows.ERROR_FILE_EXISTS) && + !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + return fmt.Errorf("create fixed trust lease: %w", err) + } + validated, validatedDirectories, err := openNativePackageRecoveryTrustLease() + if err != nil { + return fmt.Errorf("validate fixed trust lease: %w", err) + } + closeErr := windows.CloseHandle(validated) + closeNativePackageUninstallHandles(validatedDirectories) + if closeErr != nil { + return fmt.Errorf("close validated fixed trust lease: %w", closeErr) + } + return nil +} + +func resolveNativePackageLocalTestTrustPaths() (nativePackageLocalTestTrustPaths, error) { + markerPaths, err := resolveNativePackageRecoveryMarkerPaths() + if err != nil { + return nativePackageLocalTestTrustPaths{}, err + } + paths := nativePackageLocalTestTrustPaths{ + directory: markerPaths.directory, + preparing: filepath.Join(markerPaths.directory, nativePackageLocalTrustPreparingName), + pending: filepath.Join(markerPaths.directory, nativePackageLocalTrustPendingName), + owned: filepath.Join(markerPaths.directory, nativePackageLocalTrustOwnedName), + uninstalling: filepath.Join(markerPaths.directory, nativePackageLocalTrustUninstallingName), + cleared: filepath.Join(markerPaths.directory, nativePackageLocalTrustClearedName), + } + for _, path := range []string{ + paths.preparing, paths.pending, paths.owned, paths.uninstalling, paths.cleared, + } { + if !strings.EqualFold(filepath.Dir(path), paths.directory) { + return nativePackageLocalTestTrustPaths{}, errors.New( + "local-test trust journal escaped its fixed protected directory", + ) + } + } + return paths, nil +} + +func canonicalNativePackageLocalTestTrustOwnership( + record nativePackageLocalTestTrustOwnership, +) ([]byte, error) { + if record.Schema != nativePackageLocalTestTrustOwnershipSchema || + !nativePackageHexRevision.MatchString(record.SourceRevision) || + !nativePackageSHA256.MatchString(record.CertificateSHA256) || + !nativePackageSHA256.MatchString(record.PackageLockSHA256) || + record.BaselineRoot < 0 || record.BaselineRoot > 1 || + record.BaselineTrustedPublisher < 0 || record.BaselineTrustedPublisher > 1 { + return nil, errors.New("local-test trust ownership identity or baseline is invalid") + } + contents, err := json.Marshal(record) + if err != nil { + return nil, err + } + return append(contents, '\n'), nil +} + +func readNativePackageLocalTestTrustRecord( + path string, +) (nativePackageLocalTestTrustOwnership, []byte, bool, error) { + value := nativePackageLocalTestTrustOwnership{} + handle, err := lockNativePackageInput(path) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || + errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return value, nil, false, nil + } + return value, nil, false, err + } + defer windows.CloseHandle(handle) //nolint:errcheck + if err := validateNativeSecurityDescriptor( + handle, nativePackageRecoveryTrustLeaseFileSDDL, + ); err != nil { + return value, nil, false, fmt.Errorf("validate local-test trust record security: %w", err) + } + contents, err := readNativePackageRecoveryFile( + handle, nativePackageLocalTestTrustCapabilityMaximumBytes, + ) + if err != nil { + return value, nil, false, err + } + value, err = decodeCanonicalNativePackageLocalTestTrustOwnership(contents) + if err != nil { + return value, nil, false, err + } + return value, contents, true, nil +} + +func nativePackageLocalTestTrustRecordMatches( + record nativePackageLocalTestTrustOwnership, + request nativePackageRequest, +) bool { + return record.Schema == nativePackageLocalTestTrustOwnershipSchema && + record.SourceRevision == request.sourceRevision && + record.CertificateSHA256 == request.expectedLocalTestCertificateSHA256 && + record.PackageLockSHA256 == request.expectedLocalTestPackageLockSHA256 +} + +// publishNativePackageLocalTestTrustPreparing never exposes partially written +// bytes at the canonical state name. A process cut can leave only a random +// protected scratch file, which no transaction interprets as authority. +func publishNativePackageLocalTestTrustPreparing(path string, contents []byte) error { + directory := filepath.Dir(path) + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return err + } + temporary := filepath.Join( + directory, filepath.Base(path)+"."+hex.EncodeToString(nonce[:])+".scratch", + ) + if !strings.EqualFold(filepath.Dir(temporary), directory) { + return errors.New("local-test trust scratch escaped its protected directory") + } + if err := createExactNativePackageRecoveryPreparation(temporary, contents); err != nil { + return fmt.Errorf("write protected local-test trust scratch: %w", err) + } + removeTemporary := true + defer func() { + if removeTemporary { + deleteNativePackageFile(temporary) //nolint:errcheck + } + }() + if err := moveNativePackageFile(temporary, path, false); err != nil { + return fmt.Errorf("publish local-test trust preparing record: %w", err) + } + removeTemporary = false + _, observed, exists, err := readNativePackageLocalTestTrustRecord(path) + if err != nil { + return err + } + if !exists || !bytes.Equal(observed, contents) { + return errors.New("published local-test trust preparing record failed exact readback") + } + return nil +} + +func transitionNativePackageLocalTestTrustRecord( + source, destination string, + expected []byte, +) error { + _, sourceBytes, exists, err := readNativePackageLocalTestTrustRecord(source) + if err != nil { + return err + } + if !exists || !bytes.Equal(sourceBytes, expected) { + return errors.New("local-test trust source record is missing or changed") + } + if _, _, destinationExists, err := readNativePackageLocalTestTrustRecord(destination); err != nil { + return err + } else if destinationExists { + return errors.New("local-test trust destination record already exists") + } + if err := moveNativePackageFile(source, destination, false); err != nil { + return err + } + _, destinationBytes, exists, err := readNativePackageLocalTestTrustRecord(destination) + if err != nil { + return err + } + if !exists || !bytes.Equal(destinationBytes, expected) { + return errors.New("local-test trust destination record failed exact readback") + } + if _, _, sourceExists, err := readNativePackageLocalTestTrustRecord(source); err != nil { + return err + } else if sourceExists { + return errors.New("local-test trust source remained after write-through transition") + } + return nil +} + +func retireNativePackageLocalTestTrustRecord(path string, expected []byte) error { + pointer, err := windows.UTF16PtrFromString(filepath.Clean(path)) + if err != nil { + return err + } + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_READ|windows.READ_CONTROL|windows.DELETE, + windows.FILE_SHARE_READ, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || + errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil + } + return err + } + closed := false + defer func() { + if !closed { + windows.CloseHandle(handle) //nolint:errcheck + } + }() + if err := validateNativeSecurityDescriptor( + handle, nativePackageRecoveryTrustLeaseFileSDDL, + ); err != nil { + return err + } + information := windows.ByHandleFileInformation{} + if err := windows.GetFileInformationByHandle(handle, &information); err != nil { + return err + } + if information.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + return errors.New("local-test trust record is not a regular non-reparse file") + } + if err := validateNativeFileLinkCount(information.NumberOfLinks); err != nil { + return err + } + contents, err := readNativePackageRecoveryFile( + handle, nativePackageLocalTestTrustCapabilityMaximumBytes, + ) + if err != nil { + return err + } + if _, err := decodeCanonicalNativePackageLocalTestTrustOwnership(contents); err != nil { + return err + } + if expected != nil && !bytes.Equal(contents, expected) { + return errors.New("local-test trust record changed before retirement") + } + if err := deleteNativePackageUninstallFileHandle(handle); err != nil { + return err + } + if err := windows.CloseHandle(handle); err != nil { + return err + } + closed = true + return nil +} + +func addExactNativePackageLocalTestCertificate( + store windows.Handle, + certificateDER []byte, +) error { + if len(certificateDER) == 0 || len(certificateDER) > nativePackageRecoveryMaximumCertificateBytes { + return errors.New("local-test certificate length is invalid") + } + certificate, err := windows.CertCreateCertificateContext( + windows.X509_ASN_ENCODING|windows.PKCS_7_ASN_ENCODING, + &certificateDER[0], + uint32(len(certificateDER)), + ) + if err != nil { + return err + } + defer windows.CertFreeCertificateContext(certificate) //nolint:errcheck + var added *windows.CertContext + if err := windows.CertAddCertificateContextToStore( + store, certificate, windows.CERT_STORE_ADD_NEW, &added, + ); err != nil { + return err + } + if added != nil { + windows.CertFreeCertificateContext(added) //nolint:errcheck + } + return nil +} + +func countExactNativePackageLocalTestCertificateRejectingThumbprintCollisions( + store windows.Handle, + expectedDER []byte, +) (int, error) { + if len(expectedDER) == 0 || len(expectedDER) > nativePackageRecoveryMaximumCertificateBytes { + return 0, errors.New("local-test certificate length is invalid") + } + expectedThumbprint := sha1.Sum(expectedDER) // #nosec G401 -- Windows store identity collision check. + count := 0 + var previous *windows.CertContext + for enumerated := 0; enumerated < 65536; enumerated++ { + certificate, err := windows.CertEnumCertificatesInStore(store, previous) + if err != nil { + if errors.Is(err, syscall.Errno(windows.CRYPT_E_NOT_FOUND)) { + return count, nil + } + return 0, err + } + previous = certificate + if certificate == nil || certificate.EncodedCert == nil { + continue + } + encoded := unsafe.Slice(certificate.EncodedCert, int(certificate.Length)) + if bytes.Equal(encoded, expectedDER) { + count++ + continue + } + observedThumbprint := sha1.Sum(encoded) // #nosec G401 -- collision rejection only. + if observedThumbprint == expectedThumbprint { + if previous != nil { + windows.CertFreeCertificateContext(previous) //nolint:errcheck + previous = nil + } + return 0, errors.New( + "LocalMachine certificate store contains a different certificate with the same Windows SHA-1 thumbprint", + ) + } + } + if previous != nil { + windows.CertFreeCertificateContext(previous) //nolint:errcheck + } + return 0, errors.New("local-test certificate store enumeration exceeded its safety bound") +} + +func inspectNativePackageLocalTestTrust( + root windows.Handle, + publisher windows.Handle, + expectedDER []byte, +) (nativePackageRecoveryTrustCounts, error) { + rootCount, err := countExactNativePackageLocalTestCertificateRejectingThumbprintCollisions( + root, expectedDER, + ) + if err != nil { + return nativePackageRecoveryTrustCounts{}, fmt.Errorf("inspect LocalMachine Root: %w", err) + } + publisherCount, err := countExactNativePackageLocalTestCertificateRejectingThumbprintCollisions( + publisher, expectedDER, + ) + if err != nil { + return nativePackageRecoveryTrustCounts{}, fmt.Errorf( + "inspect LocalMachine TrustedPublisher: %w", err, + ) + } + return nativePackageRecoveryTrustCounts{ + root: rootCount, trustedPublisher: publisherCount, + }, nil +} + +func restoreNativePackageLocalTestTrustStores( + rootStore, publisherStore windows.Handle, + certificateDER []byte, + record nativePackageLocalTestTrustOwnership, + cutpoint func(string) error, +) error { + stores := []struct { + name string + handle windows.Handle + baseline int + cut string + }{ + {name: "Root", handle: rootStore, baseline: record.BaselineRoot, cut: "root-restored"}, + {name: "TrustedPublisher", handle: publisherStore, baseline: record.BaselineTrustedPublisher, cut: "trusted-publisher-restored"}, + } + for _, store := range stores { + if err := executeNativePackageLocalTestTrustStep( + store.cut, false, + func() error { + count, err := countExactNativePackageLocalTestCertificateRejectingThumbprintCollisions( + store.handle, certificateDER, + ) + if err != nil { + return fmt.Errorf("inspect LocalMachine %s before baseline restore: %w", store.name, err) + } + if count < 0 || count > 1 { + return fmt.Errorf("LocalMachine %s contains %d exact local-test certificates", store.name, count) + } + if count == 1 && store.baseline == 0 { + if err := deleteExactNativePackageRecoveryCertificate(store.handle, certificateDER); err != nil { + return fmt.Errorf("restore absent LocalMachine %s baseline: %w", store.name, err) + } + } else if count == 0 && store.baseline == 1 { + if err := addExactNativePackageLocalTestCertificate(store.handle, certificateDER); err != nil { + return fmt.Errorf("restore present LocalMachine %s baseline: %w", store.name, err) + } + } + count, err = countExactNativePackageLocalTestCertificateRejectingThumbprintCollisions( + store.handle, certificateDER, + ) + if err != nil || count != store.baseline { + if err == nil { + err = fmt.Errorf("observed exact count %d expected %d", count, store.baseline) + } + return fmt.Errorf("verify LocalMachine %s baseline restore: %w", store.name, err) + } + return nil + }, + cutpoint, + ); err != nil { + return err + } + } + return nil +} + +func proveNativePackageLocalTestTopologyAbsent( + ctx context.Context, + logger *slog.Logger, + helperPath string, + targetUserSID string, +) error { + deadline := time.Now().Add(nativePackageRollbackTimeout) + if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) { + deadline = contextDeadline + } + if !deadline.After(time.Now()) { + return context.DeadlineExceeded + } + output, exitCode, waitErr := executeNativePackageRecoveryHelper( + helperPath, + []string{ + "recover-failed-install-recordless", + "--transaction-deadline-unix-ms", strconv.FormatInt(deadline.UnixMilli(), 10), + }, + ) + if waitErr != nil { + var exitError *exec.ExitError + if !errors.As(waitErr, &exitError) { + return fmt.Errorf("join recordless topology proof: %w", waitErr) + } + } + if _, err := parseNativePackageRecoverProof(output, exitCode); err != nil { + return fmt.Errorf("validate recordless topology proof: %w", err) + } + statusOutput, statusExitCode, statusWaitErr := executeNativePackageRecoveryHelper( + helperPath, []string{"status"}, + ) + if statusWaitErr != nil { + var exitError *exec.ExitError + if !errors.As(statusWaitErr, &exitError) { + return fmt.Errorf("join topology status proof: %w", statusWaitErr) + } + } + if err := validateNativePackageRecoverEmptyStatus(statusOutput, statusExitCode); err != nil { + return fmt.Errorf("validate empty topology status: %w", err) + } + if err := proveNativePackageNormalBrokerJournalsQuiescent( + logger, targetUserSID, + ); err != nil { + return err + } + for _, serviceName := range []string{ + NativeBrokerServiceName, nativePackageRecoveryDriverServiceName, + } { + if err := requireNativePackageRecoveryServiceAbsent(serviceName); err != nil { + return err + } + } + return nil +} + +// proveNativePackageSettledLocalTestTopologyAbsentReadOnly is the narrow +// admission proof for replaying an already-cleared or recordless local-test +// Uninstall. Unlike the recovery/rollback proof above, it never invokes +// recordless recovery and never reconciles or retires a broker journal. Any +// journal entry can belong to a production successor, so even a validated +// settled tombstone is a hard stop on this source-bound no-op path. +func proveNativePackageSettledLocalTestTopologyAbsentReadOnly( + ctx context.Context, + helperPath string, + targetUserSID string, +) error { + if err := ctx.Err(); err != nil { + return err + } + for _, serviceName := range []string{ + NativeBrokerServiceName, nativePackageRecoveryDriverServiceName, + } { + if err := requireNativePackageRecoveryServiceAbsent(serviceName); err != nil { + return err + } + } + if err := ctx.Err(); err != nil { + return err + } + root, _, err := nativeBrokerJournalPaths(targetUserSID) + if err != nil { + return err + } + rootHandle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(root, false) + if err != nil { + if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return fmt.Errorf("open normal broker journal root read-only: %w", err) + } + } else { + defer windows.CloseHandle(rootHandle) //nolint:errcheck + entries, readErr := os.ReadDir(root) + if readErr != nil { + return fmt.Errorf("enumerate normal broker journal root read-only: %w", readErr) + } + if len(entries) != 0 { + return fmt.Errorf( + "broker journal artifact %q blocks settled local-test uninstall admission", + entries[0].Name(), + ) + } + } + if err := ctx.Err(); err != nil { + return err + } + statusOutput, statusExitCode, statusWaitErr := executeNativePackageRecoveryHelper( + helperPath, []string{"status"}, + ) + if statusWaitErr != nil { + var exitError *exec.ExitError + if !errors.As(statusWaitErr, &exitError) { + return fmt.Errorf("join read-only topology status proof: %w", statusWaitErr) + } + } + if err := validateNativePackageRecoverEmptyStatus(statusOutput, statusExitCode); err != nil { + return fmt.Errorf("validate read-only empty topology status: %w", err) + } + return ctx.Err() +} + +func proveNativePackageNormalBrokerJournalsQuiescent( + logger *slog.Logger, + targetUserSID string, +) error { + if logger == nil { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + if err := reconcileNativeBrokerJournalInactiveDirectories(logger, targetUserSID); err != nil { + return fmt.Errorf("reconcile inactive broker journals: %w", err) + } + root, _, err := nativeBrokerJournalPaths(targetUserSID) + if err != nil { + return err + } + rootHandle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(root, false) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || + errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil + } + return fmt.Errorf("open normal broker journal root: %w", err) + } + defer windows.CloseHandle(rootHandle) //nolint:errcheck + entries, err := os.ReadDir(root) + if err != nil { + return err + } + for _, entry := range entries { + name := entry.Name() + if name == nativeBrokerJournalActiveName || + isNativeBrokerJournalInactiveDirectoryName(name, nativeBrokerJournalPreparingPrefix) { + return fmt.Errorf("active or preparing broker journal blocks trust cleanup: %s", name) + } + if !entry.IsDir() || + !isNativeBrokerJournalInactiveDirectoryName(name, nativeBrokerJournalSettledPrefix) { + return fmt.Errorf("unknown broker journal artifact blocks trust cleanup: %s", name) + } + path := filepath.Join(root, name) + if !strings.EqualFold(filepath.Dir(path), root) { + return errors.New("settled broker journal escaped its protected root") + } + handle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(path, false) + if err != nil { + return fmt.Errorf("validate settled broker journal tombstone %s: %w", name, err) + } + windows.CloseHandle(handle) //nolint:errcheck + } + return nil +} + +func (t *windowsNativePackageTransaction) localTestTrustCut(name string) error { + if t.localTestTrustCutpoint == nil { + return nil } - if transaction.nestedRollbackSucceeded { - return nativePackageBrokerCommitResult{ - changed: true, rollback: "succeeded", exitCode: 1, - journal: transaction.brokerJournalProof, - }, err + return t.localTestTrustCutpoint(name) +} + +func (t *windowsNativePackageTransaction) requireLocalTestTrustLocks() error { + if t.releaseTrustLease == nil || t.releaseMutex == nil || t.releaseServiceMutex == nil { + return errors.New("local-test trust mutation requires Trust -> Package -> Service locks") } - return nativePackageBrokerCommitResult{ - changed: true, rollback: "failed", exitCode: 3, - journal: transaction.brokerJournalProof, - }, err + return nil } -func (t *windowsNativePackageTransaction) Preflight(ctx context.Context) error { - if err := ctx.Err(); err != nil { +func (t *windowsNativePackageTransaction) admitProductionLocalTestTrust() error { + if t.nestedBrokerCommit || t.request.driverValidationMode != "production" { + return nil + } + if t.releaseTrustLease == nil || t.releaseMutex == nil { + return errors.New("production trust admission requires Trust -> Package locks") + } + if t.releaseServiceMutex != nil { + return errors.New("production trust admission must precede the Service lock") + } + paths, err := resolveNativePackageLocalTestTrustPaths() + if err != nil { return err } - if t.nestedBrokerCommit { - return t.preflightNestedBrokerCommit() + type observedRecord struct { + state string + path string + bytes []byte } - mutexBudget := nativePackageTransactionTimeout - if deadline, ok := ctx.Deadline(); ok { - mutexBudget = time.Until(deadline) - if mutexBudget <= 0 { - return context.DeadlineExceeded + var observed []observedRecord + for _, candidate := range []struct { + state string + path string + }{ + {state: "preparing", path: paths.preparing}, + {state: "pending", path: paths.pending}, + {state: "owned", path: paths.owned}, + {state: "uninstalling", path: paths.uninstalling}, + {state: "cleared", path: paths.cleared}, + } { + _, contents, exists, readErr := readNativePackageLocalTestTrustRecord(candidate.path) + if readErr != nil { + return fmt.Errorf("read local-test trust %s record for production admission: %w", + candidate.state, readErr) } + if exists { + observed = append(observed, observedRecord{ + state: candidate.state, path: candidate.path, bytes: contents, + }) + } + } + states := make([]string, len(observed)) + for index := range observed { + states[index] = observed[index].state } - release, err := acquireNamedNativePackageMutex(nativePackageMutexName, mutexBudget) + retireCleared, err := nativePackageProductionLocalTrustAdmission(states) if err != nil { return err } - t.releaseMutex = release - if _, err := validateNativeInstallingUserSID(t.request.targetUserSID); err != nil { - return fmt.Errorf("validate target user SID: %w", err) + if !retireCleared { + return nil } - programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT) + if err := retireNativePackageLocalTestTrustRecord( + observed[0].path, observed[0].bytes, + ); err != nil { + return fmt.Errorf("retire validated terminal local-test trust settlement: %w", err) + } + return nil +} + +func (t *windowsNativePackageTransaction) openLocalTestTrustStores() ( + windows.Handle, windows.Handle, error, +) { + root, err := openNativePackageRecoveryCertificateStore("Root") if err != nil { - return fmt.Errorf("resolve Program Files known folder: %w", err) + return 0, 0, fmt.Errorf("open LocalMachine Root for local-test trust: %w", err) } - t.programFiles = filepath.Clean(programFiles) - t.parent = filepath.Join(t.programFiles, "VIIPER") - t.destination = filepath.Join(t.parent, "viiper.exe") - if _, err := nativeServiceExecutableParent(t.programFiles, t.destination); err != nil { + publisher, err := openNativePackageRecoveryCertificateStore("TrustedPublisher") + if err != nil { + windows.CertCloseStore(root, 0) //nolint:errcheck + return 0, 0, fmt.Errorf("open LocalMachine TrustedPublisher for local-test trust: %w", err) + } + return root, publisher, nil +} + +func (t *windowsNativePackageTransaction) prepareLocalTestTrust(ctx context.Context) error { + if t.request.driverValidationMode != "local-test" || t.nestedBrokerCommit { + return nil + } + if t.localTestTrust != nil { + return nil + } + if err := t.requireLocalTestTrustLocks(); err != nil { return err } - programFilesHandle, err := openNativePathWithoutReparse( - t.programFiles, windows.FILE_READ_ATTRIBUTES, true, - ) + if err := requireNativePackageTrustRecoveryClear(); err != nil { + return fmt.Errorf("revalidate failed-install recovery admission: %w", err) + } + paths, err := resolveNativePackageLocalTestTrustPaths() if err != nil { - return fmt.Errorf("lock Program Files root: %w", err) + return err } - t.inputHandles = append(t.inputHandles, programFilesHandle) - for _, input := range []struct { - name string - directory string + type observedRecord struct { + state string + path string + record nativePackageLocalTestTrustOwnership + bytes []byte + } + var observed []observedRecord + for _, candidate := range []struct { + state string + path string }{ - {name: "broker source", directory: filepath.Dir(t.request.brokerSource)}, - {name: "driver helper", directory: filepath.Dir(t.request.driverHelper)}, - {name: "submission manifest", directory: filepath.Dir(t.request.submissionManifest)}, - {name: "signed driver package", directory: t.request.packageDirectory}, + {state: "preparing", path: paths.preparing}, + {state: "pending", path: paths.pending}, + {state: "owned", path: paths.owned}, + {state: "uninstalling", path: paths.uninstalling}, + {state: "cleared", path: paths.cleared}, } { - handles, lockErr := lockNativePackageDirectoryChain(input.directory) - if lockErr != nil { - return fmt.Errorf("lock %s directory chain: %w", input.name, lockErr) + record, contents, exists, readErr := readNativePackageLocalTestTrustRecord(candidate.path) + if readErr != nil { + return fmt.Errorf("read local-test trust %s record: %w", candidate.state, readErr) + } + if exists { + observed = append(observed, observedRecord{ + state: candidate.state, path: candidate.path, record: record, bytes: contents, + }) } - t.inputHandles = append(t.inputHandles, handles...) - } - - t.sourceHandle, err = t.lockAndVerifyInput( - t.request.brokerSource, t.request.expectedBrokerSHA256, true, - ) - if err != nil { - return fmt.Errorf("verify installer-bound VIIPER broker: %w", err) } - t.helperHandle, err = t.lockAndVerifyInput( - t.request.driverHelper, t.request.expectedHelperSHA256, true, - ) - if err != nil { - return fmt.Errorf("verify installer-bound driver helper: %w", err) + if len(observed) > 1 { + return errors.New("multiple local-test trust ownership states exist") } - entries, err := os.ReadDir(t.request.packageDirectory) + rootStore, publisherStore, err := t.openLocalTestTrustStores() if err != nil { - return fmt.Errorf("enumerate signed driver package: %w", err) + return err } - if len(entries) != len(nativePackageDriverFiles) { - return fmt.Errorf("signed runtime driver package must contain exactly INF, SYS, and CAT, found %d files", len(entries)) + state := &nativePackageLocalTestTrustState{ + paths: paths, certificateDER: append([]byte(nil), t.localTestCertificateDER...), + rootStore: rootStore, publisherStore: publisherStore, } - for _, expected := range nativePackageDriverFiles { - matches := 0 - for _, entry := range entries { - if entry.Name() == expected && entry.Type().IsRegular() { - matches++ + t.localTestTrust = state + + if len(observed) == 1 && observed[0].state == "cleared" { + if err := retireNativePackageLocalTestTrustRecord( + observed[0].path, observed[0].bytes, + ); err != nil { + return fmt.Errorf("retire settled local-test trust record: %w", err) + } + observed = nil + } + if len(observed) == 1 { + current := observed[0] + if !nativePackageLocalTestTrustRecordMatches(current.record, t.request) { + return errors.New("active local-test trust ownership belongs to a different source-bound package") + } + state.record = current.record + state.bytes = append([]byte(nil), current.bytes...) + state.state = current.state + state.resumed = true + switch current.state { + case "preparing": + if err := transitionNativePackageLocalTestTrustRecord( + paths.preparing, paths.pending, state.bytes, + ); err != nil { + return fmt.Errorf("resume local-test trust preparing record: %w", err) } + state.state = "pending" + case "pending": + case "owned": + state.alreadyOwned = true + case "uninstalling": + if err := proveNativePackageLocalTestTopologyAbsent( + ctx, t.logger, t.request.driverHelper, t.request.targetUserSID, + ); err != nil { + return fmt.Errorf("resume local-test trust baseline restore only after topology absence: %w", err) + } + if err := restoreNativePackageLocalTestTrustStores( + rootStore, publisherStore, state.certificateDER, state.record, + t.localTestTrustCut, + ); err != nil { + return err + } + if err := executeNativePackageLocalTestTrustStep( + "cleared-published", false, + func() error { + return transitionNativePackageLocalTestTrustRecord( + paths.uninstalling, paths.cleared, state.bytes, + ) + }, + t.localTestTrustCut, + ); err != nil { + return fmt.Errorf("settle resumed local-test trust baseline restore: %w", err) + } + if err := retireNativePackageLocalTestTrustRecord(paths.cleared, state.bytes); err != nil { + return fmt.Errorf("retire resumed local-test trust settlement: %w", err) + } + t.localTestTrust = nil + windows.CertCloseStore(rootStore, 0) //nolint:errcheck + windows.CertCloseStore(publisherStore, 0) //nolint:errcheck + return t.prepareLocalTestTrust(ctx) + default: + return errors.New("unknown local-test trust ownership state") } - if matches != 1 { - return fmt.Errorf("signed driver package must contain one case-exact regular %s", expected) - } - expectedHash := map[string]string{ - "ViiperUde.inf": t.request.expectedInfSHA256, - "ViiperUde.sys": t.request.expectedSysSHA256, - "ViiperUde.cat": t.request.expectedCatSHA256, - }[expected] - handle, lockErr := t.lockAndVerifyInput( - filepath.Join(t.request.packageDirectory, expected), expectedHash, false, + } else { + counts, err := inspectNativePackageLocalTestTrust( + rootStore, publisherStore, state.certificateDER, ) - if lockErr != nil { - return fmt.Errorf("verify installer-bound signed driver file %s: %w", expected, lockErr) + if err != nil { + return err } - _ = handle + if counts.root < 0 || counts.root > 1 || + counts.trustedPublisher < 0 || counts.trustedPublisher > 1 { + return fmt.Errorf( + "local-test trust baseline must contain at most one exact certificate per store; observed Root=%d TrustedPublisher=%d", + counts.root, counts.trustedPublisher, + ) + } + state.record = nativePackageLocalTestTrustOwnership{ + Schema: nativePackageLocalTestTrustOwnershipSchema, + SourceRevision: t.request.sourceRevision, + CertificateSHA256: t.request.expectedLocalTestCertificateSHA256, + PackageLockSHA256: t.request.expectedLocalTestPackageLockSHA256, + BaselineRoot: counts.root, + BaselineTrustedPublisher: counts.trustedPublisher, + } + state.bytes, err = canonicalNativePackageLocalTestTrustOwnership(state.record) + if err != nil { + return err + } + if err := executeNativePackageLocalTestTrustStep( + "preparing-published", false, + func() error { + return publishNativePackageLocalTestTrustPreparing(paths.preparing, state.bytes) + }, + t.localTestTrustCut, + ); err != nil { + return err + } + state.state = "preparing" + state.createdCurrent = true + if err := executeNativePackageLocalTestTrustStep( + "pending-published", false, + func() error { + return transitionNativePackageLocalTestTrustRecord( + paths.preparing, paths.pending, state.bytes, + ) + }, + t.localTestTrustCut, + ); err != nil { + return fmt.Errorf("publish local-test trust pending authority: %w", err) + } + state.state = "pending" } - manifestHandle, err := t.lockAndVerifyInput( - t.request.submissionManifest, t.request.expectedManifestSHA256, false, + + counts, err := inspectNativePackageLocalTestTrust( + rootStore, publisherStore, state.certificateDER, ) if err != nil { - return fmt.Errorf("verify installer-bound driver manifest: %w", err) + return err } - _ = manifestHandle + if counts.root < 0 || counts.root > 1 || + counts.trustedPublisher < 0 || counts.trustedPublisher > 1 { + return errors.New("local-test trust contains duplicate exact certificate entries") + } + if state.alreadyOwned && (counts.root != 1 || counts.trustedPublisher != 1) { + return errors.New("owned local-test trust is missing from Root or TrustedPublisher") + } + stores := []struct { + name string + handle windows.Handle + count int + cut string + }{ + {name: "Root", handle: rootStore, count: counts.root, cut: "root-added"}, + {name: "TrustedPublisher", handle: publisherStore, count: counts.trustedPublisher, cut: "trusted-publisher-added"}, + } + for _, store := range stores { + if store.count == 0 { + if err := executeNativePackageLocalTestTrustStep( + store.cut, false, + func() error { + if err := addExactNativePackageLocalTestCertificate(store.handle, state.certificateDER); err != nil { + return fmt.Errorf("add exact local-test certificate to LocalMachine %s: %w", store.name, err) + } + count, err := countExactNativePackageLocalTestCertificateRejectingThumbprintCollisions( + store.handle, state.certificateDER, + ) + if err != nil || count != 1 { + if err == nil { + err = fmt.Errorf("observed exact count %d expected 1", count) + } + return fmt.Errorf("verify LocalMachine %s local-test trust: %w", store.name, err) + } + return nil + }, + t.localTestTrustCut, + ); err != nil { + return err + } + } + } + return nil +} - if attributes, attrErr := nativePathAttributes(t.parent); attrErr == nil { - if attributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 || - attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { - return errors.New("managed VIIPER directory is not a regular non-reparse directory") +func (t *windowsNativePackageTransaction) ensureLocalTestServiceLock(ctx context.Context) error { + if t.request.driverValidationMode != "local-test" || t.nestedBrokerCommit || + t.releaseServiceMutex != nil { + return nil + } + budget := nativePackageRollbackTimeout + if deadline, ok := ctx.Deadline(); ok { + budget = time.Until(deadline) + if budget <= 0 { + return context.DeadlineExceeded } - parent, openErr := openNativePathWithoutReparse( - t.parent, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + } + release, err := acquireNativeInstallMutex(budget) + if err != nil { + return fmt.Errorf("reacquire native broker service mutex for trust settlement: %w", err) + } + t.releaseServiceMutex = release + return nil +} + +func (t *windowsNativePackageTransaction) commitLocalTestTrust(ctx context.Context) error { + if t.request.driverValidationMode != "local-test" || t.nestedBrokerCommit { + return nil + } + if err := t.ensureLocalTestServiceLock(ctx); err != nil { + return err + } + if err := t.requireLocalTestTrustLocks(); err != nil { + return err + } + state := t.localTestTrust + if state == nil { + return errors.New("local-test package success has no native trust ownership state") + } + if !t.installProof || !t.driverInstallProofPresent || + !t.driverInstallProof.success || !t.driverHelperSettled { + return errors.New("local-test trust cannot commit without settled authenticated topology success") + } + counts, err := inspectNativePackageLocalTestTrust( + state.rootStore, state.publisherStore, state.certificateDER, + ) + if err != nil { + return err + } + if counts.root != 1 || counts.trustedPublisher != 1 { + return fmt.Errorf( + "local-test trust cannot commit without exact Root=1 TrustedPublisher=1; observed Root=%d TrustedPublisher=%d", + counts.root, counts.trustedPublisher, ) - if openErr != nil { - return fmt.Errorf("open managed VIIPER directory: %w", openErr) - } - defer windows.CloseHandle(parent) //nolint:errcheck - if validateErr := validateNativeSecurityDescriptor(parent, nativeBrokerDirectorySDDL); validateErr != nil { - return fmt.Errorf("managed VIIPER directory is not installer-owned: %w", validateErr) - } - } else if !errors.Is(attrErr, windows.ERROR_FILE_NOT_FOUND) && - !errors.Is(attrErr, windows.ERROR_PATH_NOT_FOUND) { - return fmt.Errorf("inspect managed VIIPER directory: %w", attrErr) } + if state.state == "owned" { + return nil + } + if state.state != "pending" { + return fmt.Errorf("local-test trust success cannot settle state %s", state.state) + } + if err := executeNativePackageLocalTestTrustStep( + "topology-success-before-owned", true, + func() error { + return transitionNativePackageLocalTestTrustRecord( + state.paths.pending, state.paths.owned, state.bytes, + ) + }, + t.localTestTrustCut, + ); err != nil { + return fmt.Errorf("publish local-test trust ownership after topology success: %w", err) + } + state.state = "owned" + state.alreadyOwned = true + return nil +} + +func nativePackageLocalTestTrustMayRestoreAfterFailure( + state *nativePackageLocalTestTrustState, + proof nativePackageInstallProof, + proofPresent bool, + driverHelperSettled bool, + pendingBrokerOuterSettlement bool, + driverBrokerHandoff bool, +) bool { + if state == nil || state.state != "pending" || !state.createdCurrent || + state.resumed || state.alreadyOwned || !proofPresent || !driverHelperSettled { + return false + } + return !proof.success && !proof.changed && !proof.rebootRequired && + proof.rollback == "not-needed" && (proof.exitCode == 1 || proof.exitCode == 4) && + !pendingBrokerOuterSettlement && !driverBrokerHandoff +} + +func (t *windowsNativePackageTransaction) maybeRestoreLocalTestTrustAfterFailure( + ctx context.Context, +) error { + if t.request.driverValidationMode != "local-test" || t.nestedBrokerCommit { + return nil + } + state := t.localTestTrust + if !nativePackageLocalTestTrustMayRestoreAfterFailure( + state, t.driverInstallProof, t.driverInstallProofPresent, t.driverHelperSettled, + t.pendingBrokerOuterSettlement, t.driverBrokerHandoff, + ) { + return nil + } + settleCtx, cancel := context.WithTimeout( + context.WithoutCancel(ctx), nativePackageRollbackTimeout, + ) + defer cancel() + if err := t.ensureLocalTestServiceLock(settleCtx); err != nil { + return err + } + if err := t.requireLocalTestTrustLocks(); err != nil { + return err + } + if err := proveNativePackageLocalTestTopologyAbsent( + settleCtx, t.logger, t.request.driverHelper, t.request.targetUserSID, + ); err != nil { + return fmt.Errorf("retain pending trust because topology absence is unproven: %w", err) + } + if err := transitionNativePackageLocalTestTrustRecord( + state.paths.pending, state.paths.uninstalling, state.bytes, + ); err != nil { + return fmt.Errorf("arm local-test trust baseline restore: %w", err) + } + state.state = "uninstalling" + if err := restoreNativePackageLocalTestTrustStores( + state.rootStore, state.publisherStore, state.certificateDER, state.record, + t.localTestTrustCut, + ); err != nil { + return err + } + if err := executeNativePackageLocalTestTrustStep( + "cleared-published", false, + func() error { + return transitionNativePackageLocalTestTrustRecord( + state.paths.uninstalling, state.paths.cleared, state.bytes, + ) + }, + t.localTestTrustCut, + ); err != nil { + return fmt.Errorf("publish local-test trust baseline settlement: %w", err) + } + state.state = "cleared" return nil } @@ -640,6 +2119,9 @@ func (t *windowsNativePackageTransaction) Prepare( return errors.New("native service snapshot changed before preparation") } if !t.nestedBrokerCommit { + if err := t.prepareLocalTestTrust(ctx); err != nil { + return fmt.Errorf("prepare native local-test trust transaction: %w", err) + } return t.preparePackageCoordination() } if t.nestedBrokerHealthy { @@ -753,7 +2235,10 @@ func (t *windowsNativePackageTransaction) VerifyAuthenticatedHealth(ctx context. return nil } -func (t *windowsNativePackageTransaction) Commit(context.Context) error { +func (t *windowsNativePackageTransaction) Commit(ctx context.Context) error { + if err := t.commitLocalTestTrust(ctx); err != nil { + return err + } if t.nestedBrokerCommit && t.brokerJournal != nil { if err := t.brokerJournal.appendPhase(nativeBrokerPhaseNestedReady, ""); err != nil { return fmt.Errorf("persist nested broker readiness: %w", err) @@ -907,6 +2392,10 @@ func (t *windowsNativePackageTransaction) Rollback(ctx context.Context) (resultE } } } + if trustErr := t.maybeRestoreLocalTestTrustAfterFailure(ctx); trustErr != nil { + rollbackErrors = append(rollbackErrors, + fmt.Errorf("settle native local-test trust failure: %w", trustErr)) + } return errors.Join(rollbackErrors...) } @@ -933,6 +2422,16 @@ func (t *windowsNativePackageTransaction) Close() error { if t.manager != nil { t.manager.Close() //nolint:errcheck } + if t.localTestTrust != nil { + if t.localTestTrust.rootStore != 0 { + windows.CertCloseStore(t.localTestTrust.rootStore, 0) //nolint:errcheck + t.localTestTrust.rootStore = 0 + } + if t.localTestTrust.publisherStore != 0 { + windows.CertCloseStore(t.localTestTrust.publisherStore, 0) //nolint:errcheck + t.localTestTrust.publisherStore = 0 + } + } if t.parentHandle != 0 { windows.CloseHandle(t.parentHandle) //nolint:errcheck } @@ -947,6 +2446,12 @@ func (t *windowsNativePackageTransaction) Close() error { t.releaseMutex() t.releaseMutex = nil } + if t.releaseTrustLease != nil { + if err := t.releaseTrustLease(); err != nil { + return fmt.Errorf("release package trust transaction: %w", err) + } + t.releaseTrustLease = nil + } return nil } @@ -1011,6 +2516,8 @@ func (t *windowsNativePackageTransaction) runDriverHelper(ctx context.Context) e if proofErr != nil { return fmt.Errorf("validate native driver helper proof: %w: %s", proofErr, text) } + t.driverInstallProof = proof + t.driverInstallProofPresent = true if proof.journalRecovery == "replayed" && !t.pendingBrokerOuterSettlement { return errors.New("driver helper replayed a broker journal without a preexisting pending outer settlement") } @@ -1136,12 +2643,25 @@ func (t *windowsNativePackageTransaction) runDriverHelper(ctx context.Context) e if t.driverCoordinationErr != nil { return fmt.Errorf("coordinate native broker quiescence: %w", t.driverCoordinationErr) } + if t.request.driverValidationMode == "local-test" { + lockCtx, cancel := context.WithTimeout( + context.WithoutCancel(ctx), nativePackageRollbackTimeout, + ) + lockErr := t.ensureLocalTestServiceLock(lockCtx) + cancel() + if lockErr != nil { + return lockErr + } + } if proof.exitCode == nativePackageRebootRequiredCode { return &nativePackageRebootRequiredError{cause: fmt.Errorf("%w: %s", err, text)} } if !proof.success { - return fmt.Errorf("native driver helper failed with exit %d: %w: %s", - proof.exitCode, err, text) + return &nativePackageInstallExitError{ + cause: fmt.Errorf("native driver helper failed with exit %d: %w: %s", + proof.exitCode, err, text), + exitCode: proof.exitCode, + } } return nil } diff --git a/internal/cmd/native_package_windows_test.go b/internal/cmd/native_package_windows_test.go index d26f8de5..450724f2 100644 --- a/internal/cmd/native_package_windows_test.go +++ b/internal/cmd/native_package_windows_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "slices" + "strings" "testing" "time" "unsafe" @@ -15,6 +16,386 @@ import ( "golang.org/x/sys/windows/svc/mgr" ) +func TestNativePackageLocalTestTrustCapabilityBindsParentAndPackage(t *testing.T) { + t.Parallel() + request := nativePackageRequest{ + sourceRevision: strings.Repeat("a", 40), + localTestCertificatePath: `C:\package\ViiperUdeTest.cer`, + expectedLocalTestCertificateSHA256: strings.Repeat("b", 64), + expectedLocalTestPackageLockSHA256: strings.Repeat("c", 64), + } + journalDirectory := `C:\ProgramData\VIIPER-TrustManager` + capability := nativePackageLocalTestTrustCapability{ + Schema: nativePackageLocalTestTrustCapabilitySchema, + Nonce: strings.Repeat("01", 16), + ParentPID: 1234, + ParentCreationFileTime: 134000000000000000, + SourceRevision: request.sourceRevision, + CertificatePath: request.localTestCertificatePath, + CertificateSHA256: request.expectedLocalTestCertificateSHA256, + PackageLockSHA256: request.expectedLocalTestPackageLockSHA256, + TrustJournalSchema: nativePackageLocalTestTrustOwnershipSchema, + TrustJournalDirectory: journalDirectory, + } + if err := validateNativePackageLocalTestTrustCapability( + capability, request, journalDirectory, + capability.ParentPID, capability.ParentCreationFileTime, + ); err != nil { + t.Fatalf("valid capability: %v", err) + } + mutations := map[string]func(*nativePackageLocalTestTrustCapability){ + "schema": func(value *nativePackageLocalTestTrustCapability) { value.Schema = "v2" }, + "nonce": func(value *nativePackageLocalTestTrustCapability) { value.Nonce = strings.Repeat("A", 32) }, + "parent PID": func(value *nativePackageLocalTestTrustCapability) { value.ParentPID++ }, + "parent creation": func(value *nativePackageLocalTestTrustCapability) { value.ParentCreationFileTime++ }, + "source": func(value *nativePackageLocalTestTrustCapability) { value.SourceRevision = strings.Repeat("d", 40) }, + "certificate path": func(value *nativePackageLocalTestTrustCapability) { value.CertificatePath += ".other" }, + "certificate": func(value *nativePackageLocalTestTrustCapability) { value.CertificateSHA256 = strings.Repeat("e", 64) }, + "package lock": func(value *nativePackageLocalTestTrustCapability) { value.PackageLockSHA256 = strings.Repeat("f", 64) }, + "journal schema": func(value *nativePackageLocalTestTrustCapability) { value.TrustJournalSchema = "v2" }, + "journal directory": func(value *nativePackageLocalTestTrustCapability) { + value.TrustJournalDirectory += ".other" + }, + } + for name, mutate := range mutations { + name, mutate := name, mutate + t.Run(name, func(t *testing.T) { + t.Parallel() + changed := capability + mutate(&changed) + if err := validateNativePackageLocalTestTrustCapability( + changed, request, journalDirectory, + capability.ParentPID, capability.ParentCreationFileTime, + ); err == nil { + t.Fatal("mismatched capability accepted") + } + }) + } +} + +func TestNativePackageLocalTestTrustOwnershipRequiresCanonicalBytes(t *testing.T) { + t.Parallel() + contents := []byte(`{"schema":"viiper.native.local-test-trust-ownership/v1","sourceRevision":"` + + strings.Repeat("a", 40) + `","certificateSha256":"` + strings.Repeat("b", 64) + + `","packageLockSha256":"` + strings.Repeat("c", 64) + + `","baselineRoot":0,"baselineTrustedPublisher":1}` + "\n") + value, err := decodeCanonicalNativePackageLocalTestTrustOwnership(contents) + if err != nil { + t.Fatalf("canonical ownership: %v", err) + } + if value.BaselineRoot != 0 || value.BaselineTrustedPublisher != 1 { + t.Fatalf("ownership baselines=%d/%d", value.BaselineRoot, value.BaselineTrustedPublisher) + } + for name, changed := range map[string][]byte{ + "missing LF": contents[:len(contents)-1], + "unknown field": []byte(strings.Replace(string(contents), + `"baselineRoot":0`, `"unknown":0,"baselineRoot":0`, 1)), + "duplicate field": []byte(strings.Replace(string(contents), + `"baselineRoot":0`, `"baselineRoot":0,"baselineRoot":0`, 1)), + "noncanonical whitespace": []byte(strings.Replace(string(contents), `,"sourceRevision"`, `, "sourceRevision"`, 1)), + } { + name, changed := name, changed + t.Run(name, func(t *testing.T) { + t.Parallel() + if _, err := decodeCanonicalNativePackageLocalTestTrustOwnership(changed); err == nil { + t.Fatal("noncanonical ownership bytes accepted") + } + }) + } +} + +func TestNativePackageLocalTestTrustDurableCutMatrix(t *testing.T) { + t.Parallel() + cutError := errors.New("simulated process cut") + type model struct { + journal string + root int + publisher int + topology bool + } + t.Run("protected publication", func(t *testing.T) { + t.Parallel() + for _, artifact := range []string{"lease", "preparing"} { + artifact := artifact + t.Run(artifact, func(t *testing.T) { + t.Parallel() + for _, cutName := range []string{ + "scratch-created", "scratch-written", "scratch-flushed", + "scratch-verified", "before-publish", "after-publish", + } { + cutName := cutName + t.Run(cutName, func(t *testing.T) { + t.Parallel() + type publication struct { + scratch string + flushed bool + verified bool + canonical string + } + state := publication{} + cut := func(name string) error { + if name == cutName { + return cutError + } + return nil + } + steps := []struct { + name string + cutBefore bool + op func() + }{ + {name: "scratch-created", op: func() { state.scratch = ".random.scratch" }}, + {name: "scratch-written", op: func() { state.scratch = "complete" }}, + {name: "scratch-flushed", op: func() { state.flushed = true }}, + {name: "scratch-verified", op: func() { state.verified = true }}, + {name: "before-publish", cutBefore: true, op: func() { + state.canonical = state.scratch + state.scratch = "" + }}, + {name: "after-publish", op: func() {}}, + } + var observed error + for _, step := range steps { + observed = executeNativePackageLocalTestTrustStep( + step.name, step.cutBefore, + func() error { step.op(); return nil }, cut, + ) + if observed != nil { + break + } + } + if !errors.Is(observed, cutError) { + t.Fatalf("publication cut %s was not reached: %v", cutName, observed) + } + if cutName == "after-publish" { + if state.canonical != "complete" || state.scratch != "" || + !state.flushed || !state.verified { + t.Fatalf("post-publication cut exposed incomplete authority: %+v", state) + } + return + } + if state.canonical != "" { + t.Fatalf("pre-publication cut exposed canonical authority: %+v", state) + } + // A process cut may retain only a protected, random scratch name. + // It is never interpreted as authority and a successor can retire it. + state.scratch = "" + if state.canonical != "" || state.scratch != "" { + t.Fatalf("successor could not retire inert scratch: %+v", state) + } + }) + } + }) + } + }) + t.Run("install", func(t *testing.T) { + t.Parallel() + for _, cutName := range []string{ + "preparing-published", + "pending-published", + "root-added", + "trusted-publisher-added", + "topology-success-before-owned", + } { + cutName := cutName + t.Run(cutName, func(t *testing.T) { + t.Parallel() + state := model{} + cut := func(name string) error { + if name == cutName { + return cutError + } + return nil + } + steps := []struct { + name string + cutBefore bool + op func() + }{ + {name: "preparing-published", op: func() { state.journal = "preparing" }}, + {name: "pending-published", op: func() { state.journal = "pending" }}, + {name: "root-added", op: func() { state.root = 1 }}, + {name: "trusted-publisher-added", op: func() { state.publisher = 1 }}, + {name: "topology-success-before-owned", cutBefore: true, op: func() { state.journal = "owned" }}, + } + var observed error + for _, step := range steps { + if step.name == "topology-success-before-owned" { + state.topology = true + } + observed = executeNativePackageLocalTestTrustStep( + step.name, step.cutBefore, + func() error { step.op(); return nil }, cut, + ) + if observed != nil { + break + } + } + if !errors.Is(observed, cutError) { + t.Fatalf("cut %s was not reached: %v", cutName, observed) + } + expected := map[string]model{ + "preparing-published": {journal: "preparing"}, + "pending-published": {journal: "pending"}, + "root-added": {journal: "pending", root: 1}, + "trusted-publisher-added": {journal: "pending", root: 1, publisher: 1}, + "topology-success-before-owned": {journal: "pending", root: 1, publisher: 1, topology: true}, + }[cutName] + if state != expected { + t.Fatalf("cut %s durable model=%+v want=%+v", cutName, state, expected) + } + if cutName == "topology-success-before-owned" { + if !state.topology || state.journal != "pending" || state.root != 1 || state.publisher != 1 { + t.Fatalf("success-before-owned cut lost pending authority: %+v", state) + } + // A successor repairs/revalidates the exact installed topology and + // only then performs the same pre-cut Owned publication step. + if err := executeNativePackageLocalTestTrustStep( + "topology-success-before-owned", true, + func() error { state.journal = "owned"; return nil }, nil, + ); err != nil { + t.Fatal(err) + } + if state.journal != "owned" { + t.Fatalf("successor did not settle owned: %+v", state) + } + } + }) + } + }) + + t.Run("baseline restore", func(t *testing.T) { + t.Parallel() + for _, cutName := range []string{ + "root-restored", "trusted-publisher-restored", "cleared-published", + } { + cutName := cutName + t.Run(cutName, func(t *testing.T) { + t.Parallel() + state := model{journal: "uninstalling", root: 1, publisher: 1} + cut := func(name string) error { + if name == cutName { + return cutError + } + return nil + } + steps := []struct { + name string + op func() + }{ + {name: "root-restored", op: func() { state.root = 0 }}, + {name: "trusted-publisher-restored", op: func() { state.publisher = 0 }}, + {name: "cleared-published", op: func() { state.journal = "cleared" }}, + } + var observed error + for _, step := range steps { + observed = executeNativePackageLocalTestTrustStep( + step.name, false, + func() error { step.op(); return nil }, cut, + ) + if observed != nil { + break + } + } + if !errors.Is(observed, cutError) || state.journal != "uninstalling" && cutName != "cleared-published" { + t.Fatalf("restore cut %s left unsafe model %+v error=%v", cutName, state, observed) + } + // All store operations are idempotent and an uninstalling retry + // re-proves topology absence before completing the same sequence. + state.root, state.publisher, state.journal = 0, 0, "cleared" + if state.root != 0 || state.publisher != 0 || state.journal != "cleared" { + t.Fatalf("restore retry did not settle: %+v", state) + } + }) + } + }) +} + +func TestNativePackageLocalTestTrustFailureCleanupRejectsAmbiguousOrResumedAuthority(t *testing.T) { + t.Parallel() + baseState := nativePackageLocalTestTrustState{state: "pending", createdCurrent: true} + baseProof := nativePackageInstallProof{ + success: false, changed: false, rebootRequired: false, + rollback: "not-needed", exitCode: 4, + } + if !nativePackageLocalTestTrustMayRestoreAfterFailure( + &baseState, baseProof, true, true, false, false, + ) { + t.Fatal("exact fresh settled no-change failure was not eligible for topology-gated restore") + } + validExitOne := baseProof + validExitOne.exitCode = 1 + if !nativePackageLocalTestTrustMayRestoreAfterFailure( + &baseState, validExitOne, true, true, false, false, + ) { + t.Fatal("exact fresh exit-1 no-change failure was not eligible for topology-gated restore") + } + cases := map[string]func(*nativePackageLocalTestTrustState, *nativePackageInstallProof) (bool, bool, bool, bool){ + "resumed pending": func(state *nativePackageLocalTestTrustState, _ *nativePackageInstallProof) (bool, bool, bool, bool) { + state.resumed = true + return true, true, false, false + }, + "prior owned": func(state *nativePackageLocalTestTrustState, _ *nativePackageInstallProof) (bool, bool, bool, bool) { + state.alreadyOwned = true + return true, true, false, false + }, + "not current": func(state *nativePackageLocalTestTrustState, _ *nativePackageInstallProof) (bool, bool, bool, bool) { + state.createdCurrent = false + return true, true, false, false + }, + "wrong state": func(state *nativePackageLocalTestTrustState, _ *nativePackageInstallProof) (bool, bool, bool, bool) { + state.state = "uninstalling" + return true, true, false, false + }, + "proof missing": func(_ *nativePackageLocalTestTrustState, _ *nativePackageInstallProof) (bool, bool, bool, bool) { + return false, true, false, false + }, + "helper unsettled": func(_ *nativePackageLocalTestTrustState, _ *nativePackageInstallProof) (bool, bool, bool, bool) { + return true, false, false, false + }, + "success": func(_ *nativePackageLocalTestTrustState, proof *nativePackageInstallProof) (bool, bool, bool, bool) { + proof.success = true + return true, true, false, false + }, + "changed": func(_ *nativePackageLocalTestTrustState, proof *nativePackageInstallProof) (bool, bool, bool, bool) { + proof.changed = true + return true, true, false, false + }, + "reboot": func(_ *nativePackageLocalTestTrustState, proof *nativePackageInstallProof) (bool, bool, bool, bool) { + proof.rebootRequired = true + return true, true, false, false + }, + "rollback": func(_ *nativePackageLocalTestTrustState, proof *nativePackageInstallProof) (bool, bool, bool, bool) { + proof.rollback = "succeeded" + return true, true, false, false + }, + "exit 3": func(_ *nativePackageLocalTestTrustState, proof *nativePackageInstallProof) (bool, bool, bool, bool) { + proof.exitCode = 3 + return true, true, false, false + }, + "broker settlement": func(_ *nativePackageLocalTestTrustState, _ *nativePackageInstallProof) (bool, bool, bool, bool) { + return true, true, true, false + }, + "broker handoff": func(_ *nativePackageLocalTestTrustState, _ *nativePackageInstallProof) (bool, bool, bool, bool) { + return true, true, false, true + }, + } + for name, mutate := range cases { + name, mutate := name, mutate + t.Run(name, func(t *testing.T) { + t.Parallel() + state := baseState + proof := baseProof + present, settled, brokerSettlement, handoff := mutate(&state, &proof) + if nativePackageLocalTestTrustMayRestoreAfterFailure( + &state, proof, present, settled, brokerSettlement, handoff, + ) { + t.Fatal("ambiguous or successor-owned failure authorized trust restore") + } + }) + } +} + func TestNativePackageDriverCoordinationUsesDistinctInheritedEvents(t *testing.T) { t.Parallel() coordination, err := newNativePackageDriverCoordination() diff --git a/internal/config/config.go b/internal/config/config.go index 5c06ae53..b1574b92 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,5 +35,6 @@ type CLI struct { Install cmd.Install `cmd:"" help:"Add the current VIIPER executable to system startup and runs it (creates a Systemd service on Linux)"` Uninstall cmd.Uninstall `cmd:"" help:"Remove any VIIPER system startup configuration / Systemd service"` NativePackageInstall cmd.NativePackageInstall `cmd:"" name:"native-package-install" help:"Install a verified native UDE package and broker transactionally" hidden:""` + NativePackageRecover cmd.NativePackageRecover `cmd:"" name:"native-package-recover" help:"Reconcile only retained native package journals" hidden:""` NativePackageBrokerCommit cmd.NativePackageBrokerCommit `cmd:"" name:"native-package-broker-commit" help:"Commit the broker inside an active native package transaction" hidden:""` } diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index c21f0f36..8b8c1241 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -9,7 +9,7 @@ import ( "testing" ) -func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { +func TestLocalTestPackageUsesNativeTrustTransaction(t *testing.T) { root := filepath.Join("..", "..", "..") read := func(path ...string) string { t.Helper() @@ -24,21 +24,6 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { for _, required := range []string{ "workflow_dispatch:", "New-ViiperUdeLocalTestPackage.ps1", - "[Security.Cryptography.X509Certificates.X509Store]::new(", - "ViiperNativeCertificateStore", - "CertAddEncodedCertificateToStore(", - "CertFindCertificateInStore(", - "CertDeleteCertificateFromStore(found)", - "CERT_STORE_ADD_NEW", - "$addedTrust += $storeName", - "CERT_SYSTEM_STORE_LOCAL_MACHINE", - "[Security.Cryptography.X509Certificates.StoreName]::Root", - "[Security.Cryptography.X509Certificates.StoreName]::TrustedPublisher", - "[Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine", - "foreach ($storeName in $addedTrust)", - "$cleanupErrors.Add(", - "$certificate.Dispose()", - "-BrokerPath native/udecx/x64/Release/viiper.exe", "ViiperUde-x64-local-test-${{ github.sha }}", "path: native/udecx/x64/Release/ViiperUdeLocalTest/**", "retention-days: 7", @@ -47,44 +32,22 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { t.Fatalf("local-test workflow omitted %q", required) } } - for _, forbidden := range []string{ - "native/udecx/x64/Release/**", - "native/udecx/driver/x64/Release/**", - "native/udecx/package/x64/Release/**", - "$store.Add($certificate)", - "$store.Remove($exactMatch[0])", - "certutil.exe", - "Invoke-BoundedCertUtil", - "CERT_SYSTEM_STORE_CURRENT_USER", - "[Security.Cryptography.X509Certificates.StoreLocation]::CurrentUser", - } { - if strings.Contains(workflow, forbidden) { - t.Fatalf("local-test workflow uploads broad build tree %q", forbidden) - } - } composer := read("native", "udecx", "tools", "New-ViiperUdeLocalTestPackage.ps1") for _, required := range []string{ "[string]$BrokerPath", "[string]$TestCertificatePath", - "$certificateSha256 = Get-CertificateSha256 $expectedCertificate", - "Resolve-ExactInput $BrokerPath 'viiper.exe'", - "signingRoute = 'LocalTest'", - "releaseEligible = $false", "testSignerCertificateSha256", "installerScriptSha256", - "-ValidationMode LocalTest", - "-RequireLocalTestToolchainValidation", "local-test-package.lock.json", - "Local test package lock SHA-256: $lockSha256", "$broker native-package-install --help", - "$expectedBrokerFlags", - "$broker native-package-broker-commit --help", - "$expectedBrokerCommitFlags", - "'--expected-token-sha-256'", - "'--expected-broker-sha-256'", - "$helper verify (Join-Path $driverDirectory 'ViiperUde.inf')", - "result=success operation=verify changed=0 rebootRequired=0 rollback=not-needed exitCode=0", + "'--local-test-trust-capability'", + "'--expected-trust-capability-sha-256'", + "'--local-test-certificate-path'", + "'--expected-local-test-certificate-sha-256'", + "'--expected-local-test-package-lock-sha-256'", + "System32\\WindowsPowerShell\\v1.0\\powershell.exe", + "-PreflightOnly", } { if !strings.Contains(composer, required) { t.Fatalf("local-test composer omitted %q", required) @@ -97,334 +60,194 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "[string]$ExpectedPackageLockSHA256", "$installerScriptStream", "$lock.installerScriptSha256 -cne $actualInstallerScriptSha256", - "$lockAlgorithm.ComputeHash($lockBytes)", - "@(Compare-Object -ReferenceObject $wanted -DifferenceObject $actual -CaseSensitive).Count", "out-of-band workflow digest", - "O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)", - "[IO.Directory]::CreateDirectory($Path, $expectedSecurity)", - "$directory.SetAccessControl($expectedSecurity)", "Assert-ProtectedStagingDirectory", - "$actualSecurity.AreAccessRulesProtected", - "$actualSecurity.GetOwner([Security.Principal.SecurityIdentifier])", - "$actualSecurity.GetAccessRules(", - "@('S-1-5-18', 'S-1-5-32-544')", - "[Security.AccessControl.FileSystemRights]::FullControl", - "[Security.AccessControl.InheritanceFlags]::ContainerInherit", - "[Security.AccessControl.InheritanceFlags]::ObjectInherit", "Copy-ExactBrokerToProtectedStage", - "[IO.FileShare]::Read", "[IO.FileOptions]::WriteThrough", - "$lockByPath['viiper.exe']", - "Remove-ProtectedStagingDirectory", "Remove-PreBootProtectedStagingDirectories", - "public static class ViiperWindowsUptime", - "public static extern ulong GetTickCount64();", - "Get-WindowsBootBoundaryUtc", - "$_.LastWriteTimeUtc -lt $bootBoundaryUtc", "Invoke-JoinedNativeProcess", - "if (-not $process.Start())", - "$Started.Value = $true", "$process.WaitForExit()", - "$retainTrustOnFailure = $processStarted", - "'--expected-broker-sha-256', $brokerHash", - "'--expected-helper-sha-256', $helperHash", - "'--expected-manifest-sha-256', $manifestHash", - "'--expected-inf-sha-256', $infHash", - "'--expected-sys-sha-256', $sysHash", - "'--expected-cat-sha-256', $catHash", - "'--target-user-sid', $TargetUserSID", - "'--driver-validation-mode', 'local-test'", + "New-LocalTestTrustCapability", + "Remove-LocalTestTrustCapability", + "viiper.native.local-test-trust-capability/v1", + "parentCreationFileTime", + "certificatePath = [IO.Path]::GetFullPath($CertificatePath)", + "trustJournalSchema = 'viiper.native.local-test-trust-ownership/v1'", + "trustJournalDirectory = [IO.Path]::GetFullPath($TrustJournalDirectory)", + "'--local-test-certificate-path', $certificatePath", + "'--expected-local-test-certificate-sha-256', $certificateSha256", + "'--expected-local-test-package-lock-sha-256', $actualPackageLockSha256", "-AcknowledgeDisposableTestMachine", "testsigning\\s+Yes", - "Restart, rerun this identical install command", "[switch]$PreflightOnly", "operation=local-test-preflight", - "ViiperLocalTestCertificateStore", - "CertAddEncodedCertificateToStore(", - "CertFindCertificateInStore(", - "CertDeleteCertificateFromStore(found)", - "CERT_STORE_ADD_NEW", - "CRYPT_E_NOT_FOUND", - "Get-ExactLocalTestTrustState", - "$addedStores.Add($storeName)", - "[Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly", - "action=verify-add result=present", - "action=verify-cleanup result=absent", - "LocalMachine\\$storeName trust cleanup failed during $cleanupAction.", - "ExactSpelling = true", - "[Parameter(Mandatory = $true)][int]$ProcessExitCode", - "[string]::Join([Environment]::NewLine, [string[]]$Lines)", - "[int]::TryParse($match.Groups['exit'].Value, [ref]$proofExitCode)", - "$proofExitCode -ne $ProcessExitCode", - "-Lines $output -ProcessExitCode $exitCode", - "$certificateStoreOpenMethod = [ViiperLocalTestCertificateStore].GetMethod(", - "$certificateStoreOpenImport.ExactSpelling", - "does not bind the exact CertOpenStore entry point", + "retained its durable trust/package authority", } { if !strings.Contains(installer, required) { t.Fatalf("local-test installer omitted %q", required) } } - for _, required := range []string{ - "System32\\WindowsPowerShell\\v1.0\\powershell.exe", - "-PreflightOnly", - "Windows PowerShell 5.1 local-test installer preflight failed", - } { - if !strings.Contains(composer, required) { - t.Fatalf("local-test composer omitted Windows PowerShell preflight contract %q", required) - } - } for _, forbidden := range []string{ - "& $helperPath install", - "Test-ViiperUdeSignedPackage.ps1", - "git.exe", - "status --porcelain", - "'--expected-broker-sha256'", - "'--expected-helper-sha256'", - "'--expected-manifest-sha256'", - "'--expected-inf-sha256'", - "'--expected-sys-sha256'", - "'--expected-cat-sha256'", - "GetSecurityDescriptorBinaryForm", - "BinaryLength", + "[Security.Cryptography.X509Certificates.X509Store]::new(", + "ViiperLocalTestCertificateStore", + "CertAddEncodedCertificateToStore(", + "CertDeleteCertificateFromStore(", + "Open-LocalTestTrustOwnershipJournal", + "Complete-LocalTestTrustOwnershipInstall", + "Restore-LocalTestTrustOwnershipBaseline", + "local-test-trust-preparing-v1.json", + "local-test-trust-pending-v1.json", + "local-test-trust-owned-v1.json", + "Enter-LocalTestTrustLease", + "Exit-LocalTestTrustLease", + "$stream.Lock(0, 1)", + "$stream.Unlock(0, 1)", + "Test-SettledLocalTestFailure", + "trustJournalState", + "trustJournalSha256", + "leasePath", "$store.Add($certificate)", "$store.Remove(", - "[Environment]::TickCount64", - "[Environment]::TickCount", } { if strings.Contains(installer, forbidden) { - t.Fatalf("local-test elevated path retained unsafe dependency %q", forbidden) + t.Fatalf("PowerShell retained forbidden trust ownership operation %q", forbidden) } } - cleanupStart := strings.Index(installer, "function Remove-NewLocalTestTrust") - cleanupEnd := strings.Index(installer, "function Test-SettledLocalTestFailure") - if cleanupStart < 0 || cleanupEnd <= cleanupStart { - t.Fatal("local-test installer trust cleanup function is missing or malformed") - } - cleanup := installer[cleanupStart:cleanupEnd] - remove := strings.Index(cleanup, "[ViiperLocalTestCertificateStore]::Remove(") - verify := strings.LastIndex(cleanup, "Get-ExactLocalTestTrustState -StoreName $storeName") - absence := strings.Index(cleanup, "if ($cleanupState.ExactCount -ne 0)") - if remove < 0 || verify <= remove || absence <= verify { - t.Fatal("local-test installer does not verify persisted exact-certificate absence after native removal") - } - if strings.Count(cleanup, "catch {") != 1 || - strings.Index(cleanup, "$removalErrors.Add(") < strings.Index(cleanup, "catch {") { - t.Fatal("local-test installer does not independently aggregate per-store cleanup failures") - } - preflightStart := strings.Index(installer, "if ($PreflightOnly) {") - interopCompile := strings.Index(installer, "if (-not ('ViiperLocalTestCertificateStore' -as [type])) {") - interopVerify := strings.Index(installer, "$certificateStoreOpenMethod = [ViiperLocalTestCertificateStore].GetMethod(") - preflightSuccess := strings.Index(installer, - "Write-Output 'result=success operation=local-test-preflight changed=0 rebootRequired=0 rollback=not-needed exitCode=0'") - trustAddCall := strings.Index(installer, "[ViiperLocalTestCertificateStore]::Add(") - trustRemoveCall := strings.Index(installer, "[ViiperLocalTestCertificateStore]::Remove(") - if preflightStart < 0 || interopCompile <= preflightStart || interopVerify <= interopCompile || - preflightSuccess <= interopVerify || trustAddCall <= preflightSuccess || - trustRemoveCall <= preflightSuccess { - t.Fatal("local-test preflight can return success before compiling and inspecting the exact certificate-store interop") - } - if strings.Contains(installer[preflightStart:interopCompile], "return") { - t.Fatal("local-test preflight can return before compiling the exact certificate-store interop") - } - preflightCleanup := strings.Index(installer[preflightStart:preflightSuccess], - "Remove-PreBootProtectedStagingDirectories") - preflightOldAssertion := strings.Index(installer[preflightStart:preflightSuccess], - "Pre-boot protected staging cleanup did not remove its test directory.") - preflightCurrentAssertion := strings.Index(installer[preflightStart:preflightSuccess], - "Pre-boot protected staging cleanup removed a same-boot test directory.") - if preflightCleanup < 0 || preflightOldAssertion <= preflightCleanup || - preflightCurrentAssertion <= preflightOldAssertion { - t.Fatal("local-test preflight does not execute both sides of pre-boot staging cleanup") - } - settledStart := strings.Index(installer, "function Test-SettledLocalTestFailure") - settledEnd := strings.Index(installer, "$trustCommitted = $false") - if settledStart < 0 || settledEnd <= settledStart { - t.Fatal("local-test installer settled-failure predicate is missing or malformed") - } - settled := installer[settledStart:settledEnd] - if strings.Contains(settled, "$Lines | Out-String") { - t.Fatal("local-test installer formats and host-wraps native settled-failure proof before parsing") - } - joinLines := strings.Index(settled, "[string]::Join([Environment]::NewLine, [string[]]$Lines)") - parseProof := strings.Index(settled, "[regex]::Matches($proofText, $pattern)") - parseExit := strings.Index(settled, "[int]::TryParse($match.Groups['exit'].Value, [ref]$proofExitCode)") - bindExit := strings.Index(settled, "$proofExitCode -ne $ProcessExitCode") - classify := strings.Index(settled, "$match.Groups['changed'].Value -ceq '0'") - if joinLines < 0 || parseProof <= joinLines || parseExit <= parseProof || - bindExit <= parseExit || classify <= bindExit { - t.Fatal("local-test installer classifies settled proof before binding it to the observed child exit") + capabilityCreate := strings.LastIndex(installer, "$trustCapability = New-LocalTestTrustCapability") + transactionLaunch := strings.LastIndex(installer, "$processResult = Invoke-JoinedNativeProcess") + capabilityCleanup := strings.LastIndex(installer, "Remove-LocalTestTrustCapability") + stageCleanup := strings.LastIndex(installer, "Remove-ProtectedStagingDirectory") + if capabilityCreate < 0 || transactionLaunch <= capabilityCreate || + capabilityCleanup <= transactionLaunch || stageCleanup <= capabilityCleanup { + t.Fatal("PowerShell does not hold the sealed parent capability through the joined native child") } packageCommand := read("internal", "cmd", "native_package.go") packageWindows := read("internal", "cmd", "native_package_windows.go") - helperSource := read("native", "udecx", "tools", "ViiperUdeCtl.cpp") - for _, required := range []string{ - "BuildBrokerCommitCommandLine(", - `L" --expected-token-sha-256 "`, - `L" --expected-broker-sha-256 "`, - `L"self-test-broker-command"`, - } { - if !strings.Contains(helperSource, required) { - t.Fatalf("native helper omitted nested broker command contract %q", required) - } - } - for _, obsolete := range []string{ - `L" --expected-token-sha256 "`, - `L" --expected-broker-sha256 "`, - } { - if strings.Contains(helperSource, obsolete) { - t.Fatalf("native helper retained obsolete nested broker option %q", obsolete) - } - } for _, required := range []string{ + "LocalTestCertificatePath", + "localTestCertificatePath", `default:"production" enum:"production,local-test"`, - `r.driverValidationMode != "production" && r.driverValidationMode != "local-test"`, } { if !strings.Contains(packageCommand, required) { t.Fatalf("native package command omitted %q", required) } } - if !strings.Contains(packageWindows, - `"--validation-mode", t.request.driverValidationMode`) { - t.Fatal("native package transaction does not pass the validated signature route to its retained helper") - } - if !strings.Contains(helperSource, - `if (!SetupGetStringFieldW(&context, field, nullptr, 0, &required) ||`) { - t.Fatal("native helper does not honor SetupGetStringFieldW's successful size-query contract") - } - if strings.Contains(helperSource, - "SetupGetStringFieldW(&context, field, nullptr, 0, &required);\n"+ - " if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER)") { - t.Fatal("native helper still treats a successful SetupGetStringFieldW size query as failure") - } - if strings.Count(helperSource, - "code != ERROR_AUTHENTICODE_TRUSTED_PUBLISHER") != 1 { - t.Fatal("native helper does not retain SetupAPI's exact trusted-Authenticode classification for installed packages") - } for _, required := range []string{ - "bool allowUntrustedLocalTestRoot", - "allowUntrustedLocalTestRoot &&", - "status == static_cast(CERT_E_UNTRUSTEDROOT)", - "VerifyDriverCatalogMember(catalogPath, infPath, true, error)", - "VerifyDriverCatalogMember(catalogPath, infPath, false, error)", + "initializeNativePackageRecoveryTrustLease", + "acquireNativePackageRecoveryTrustLease", + "prepareLocalTestTrust", + "commitLocalTestTrust", + "maybeRestoreLocalTestTrustAfterFailure", + "publishNativePackageLocalTestTrustPreparing", + "transitionNativePackageLocalTestTrustRecord", + "restoreNativePackageLocalTestTrustStores", + "proveNativePackageLocalTestTopologyAbsent", + "topology-success-before-owned", + "Trust -> Package -> Service", } { - if !strings.Contains(helperSource, required) { - t.Fatalf("native helper omitted scoped pre-trust catalog policy %q", required) + if !strings.Contains(packageWindows, required) { + t.Fatalf("native trust transaction omitted %q", required) } } - if strings.Contains(helperSource, - "ERROR_AUTHENTICODE_TRUST_NOT_ESTABLISHED") { - t.Fatal("native helper accepts an Authenticode publisher that is not in TrustedPublisher") - } - if !strings.Contains(helperSource, - "GUID action = WINTRUST_ACTION_GENERIC_VERIFY_V2;") { - t.Fatal("native helper does not use Authenticode policy for exact catalog-member verification") - } - if strings.Contains(helperSource, - "GUID action = DRIVER_ACTION_VERIFY;") { - t.Fatal("native helper incorrectly uses the WHQL-only policy for test catalog membership") + trustAcquire := strings.Index(packageWindows, "acquireNativePackageRecoveryTrustLease(ctx, trustDeadline)") + packageAcquire := strings.Index(packageWindows, "acquireNamedNativePackageMutex(nativePackageMutexName") + serviceAcquire := strings.Index(packageWindows, "acquireNativeInstallMutex(budget)") + if trustAcquire < 0 || packageAcquire <= trustAcquire || serviceAcquire <= packageAcquire { + t.Fatal("normal native transaction does not acquire Trust -> Package -> Service") } } -func TestLocalTestSettledFailureRequiresObservedExitMatch(t *testing.T) { +func TestLocalTestCapabilitySerializesOnWindowsPowerShellHosts(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows PowerShell contract") } - root := filepath.Join("..", "..", "..") installer, err := filepath.Abs(filepath.Join( root, "native", "udecx", "tools", "Install-ViiperUdeLocalTest.ps1")) if err != nil { t.Fatalf("resolve local-test installer: %v", err) } - powerShell := filepath.Join( - os.Getenv("SystemRoot"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe") - if _, err := os.Stat(powerShell); err != nil { - t.Fatalf("locate Windows PowerShell: %v", err) + hosts := []string{filepath.Join( + os.Getenv("SystemRoot"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe")} + if pwsh, err := exec.LookPath("pwsh.exe"); err == nil { + hosts = append(hosts, pwsh) } - const behaviorContract = ` $ErrorActionPreference = 'Stop' +$tokens = $null +$errors = $null $source = Get-Content -LiteralPath $env:VIIPER_INSTALLER_CONTRACT_PATH -Raw +[void][Management.Automation.Language.Parser]::ParseFile( + $env:VIIPER_INSTALLER_CONTRACT_PATH, [ref]$tokens, [ref]$errors) +if ($errors.Count -ne 0) { throw ($errors | ForEach-Object ToString | Out-String) } +foreach ($forbidden in @( + 'ViiperLocalTestCertificateStore', 'CertAddEncodedCertificateToStore', + 'CertDeleteCertificateFromStore', 'Enter-LocalTestTrustLease', + 'Open-LocalTestTrustOwnershipJournal', 'Test-SettledLocalTestFailure')) { + if ($source.Contains($forbidden)) { throw "PowerShell retained forbidden trust writer $forbidden" } +} $csharpBlocks = @([regex]::Matches( $source, "(?s)Add-Type -Language CSharp -TypeDefinition @'\r?\n(?.*?)\r?\n'@") | ForEach-Object { $_.Groups['source'].Value } | - Where-Object { $_ -match 'public static class ViiperLocalTestCertificateStore' }) -if ($csharpBlocks.Count -ne 1) { throw 'Embedded certificate-store source was not found exactly once.' } + Where-Object { $_ -match 'public static class ViiperLocalTestStagingNative' }) +if ($csharpBlocks.Count -ne 1) { throw 'Protected-staging native source was not found exactly once.' } Add-Type -Language CSharp -TypeDefinition $csharpBlocks[0] -$openStore = [ViiperLocalTestCertificateStore].GetMethod( - 'CertOpenStore', [Reflection.BindingFlags]'NonPublic,Static') -$import = $openStore.GetCustomAttributes( - [Runtime.InteropServices.DllImportAttribute], $false)[0] -if ($import.Value -cne 'crypt32.dll' -or -not $import.ExactSpelling -or - $import.CharSet -ne [Runtime.InteropServices.CharSet]::Unicode) { - throw 'CertOpenStore P/Invoke metadata does not name the exact native entry point.' +$capabilityStart = $source.IndexOf('function New-LocalTestTrustCapability') +$capabilityEnd = $source.IndexOf('function Remove-LocalTestTrustCapability', $capabilityStart) +if ($capabilityStart -lt 0 -or $capabilityEnd -le $capabilityStart) { + throw 'Capability function extent was not found.' } - -$start = $source.IndexOf('function Test-SettledLocalTestFailure') -$end = $source.IndexOf('$trustCommitted = $false', $start) -if ($start -lt 0 -or $end -le $start) { throw 'Settled-failure predicate was not found.' } -Invoke-Expression $source.Substring($start, $end - $start) -$settled = @( - 'VIIPER: error: install native driver and broker transaction: native driver helper failed with exit 1: exit status 1:', - ('result=error operation=install changed=1 rebootRequired=0 rollback=succeeded exitCode=1 ' + - 'phase="broker-preflight" win32Error=1603 nestedExitCode=4 ' + - 'message="nested broker transaction failed after proving a settled state; nested diagnostic: ' + - 'lock package transaction token: The process cannot access the file because it is being used by another process."') -) -if ($settled[1].Length -le 120) { - throw 'Settled proof fixture does not exceed the live host width.' -} -if (-not (Test-SettledLocalTestFailure -Lines $settled -ProcessExitCode 1)) { - throw 'Matching long settled proof was rejected.' -} -$retainTrustOnFailure = $true -if (Test-SettledLocalTestFailure -Lines $settled -ProcessExitCode 1) { - $retainTrustOnFailure = $false -} -if ($retainTrustOnFailure) { - throw 'Matching long settled proof did not authorize trust removal.' -} -$cleanupCalls = 0 -$trustCommitted = $false -try { - throw 'simulated post-process transaction failure' -} -catch { - if (-not $trustCommitted -and -not $retainTrustOnFailure) { - $cleanupCalls++ - } +$capabilitySource = $source.Substring($capabilityStart, $capabilityEnd - $capabilityStart) +$orderedFields = @( + 'schema =', 'nonce =', 'parentPid =', 'parentCreationFileTime =', + 'sourceRevision =', 'certificatePath =', 'certificateSha256 =', + 'packageLockSha256 =', 'trustJournalSchema =', 'trustJournalDirectory =') +$previous = -1 +foreach ($field in $orderedFields) { + $matches = [regex]::Matches( + $capabilitySource, ('(?m)^\s*' + [regex]::Escape($field))) + if ($matches.Count -ne 1) { throw "Capability field occurrence failed at $field" } + $position = $matches[0].Index + if ($position -le $previous) { throw "Capability field order failed at $field" } + $previous = $position } -if ($cleanupCalls -ne 1) { - throw 'Settled rollback did not enter the trust-cleanup branch exactly once.' +$payload = [ordered]@{ + schema = 'viiper.native.local-test-trust-capability/v1' + nonce = '01010101010101010101010101010101' + parentPid = [uint32]1234 + parentCreationFileTime = [uint64]134000000000000000 + sourceRevision = ('a' * 40) + certificatePath = 'C:\package\ViiperUdeTest.cer' + certificateSha256 = ('b' * 64) + packageLockSha256 = ('c' * 64) + trustJournalSchema = 'viiper.native.local-test-trust-ownership/v1' + trustJournalDirectory = 'C:\ProgramData\VIIPER-TrustManager' } -$retainTrustOnFailure = $true -if (Test-SettledLocalTestFailure -Lines $settled -ProcessExitCode 4) { - $retainTrustOnFailure = $false +$json = $payload | ConvertTo-Json -Compress -Depth 2 +$roundTrip = $json | ConvertFrom-Json +if ([string]$roundTrip.schema -cne 'viiper.native.local-test-trust-capability/v1' -or + [string]$roundTrip.certificatePath -cne 'C:\package\ViiperUdeTest.cer' -or + [string]$roundTrip.trustJournalSchema -cne 'viiper.native.local-test-trust-ownership/v1' -or + [string]$roundTrip.trustJournalDirectory -cne 'C:\ProgramData\VIIPER-TrustManager') { + throw "Capability JSON did not round-trip exactly: $json" } -if (-not $retainTrustOnFailure) { - throw 'Mismatched proof exit incorrectly authorized trust removal.' -} -$preflight = @( - 'result=error operation=install changed=0 rebootRequired=0 rollback=not-needed exitCode=4 phase="preflight"' -) -if (-not (Test-SettledLocalTestFailure -Lines $preflight -ProcessExitCode 4)) { - throw 'Matching settled preflight proof was rejected.' -} -if (Test-SettledLocalTestFailure -Lines $preflight -ProcessExitCode 1) { - throw 'Mismatched preflight proof was accepted.' +if ($json.IndexOf([char]13) -ge 0 -or $json.IndexOf([char]10) -ge 0) { + throw 'Capability JSON contains noncanonical framing.' } ` - command := exec.Command( - powerShell, "-NoProfile", "-NonInteractive", "-Command", behaviorContract) - command.Env = append(os.Environ(), "VIIPER_INSTALLER_CONTRACT_PATH="+installer) - if output, err := command.CombinedOutput(); err != nil { - t.Fatalf("settled-failure behavior contract failed: %v\n%s", err, output) + for _, host := range hosts { + host := host + t.Run(filepath.Base(filepath.Dir(host))+"-"+filepath.Base(host), func(t *testing.T) { + command := exec.Command(host, "-NoProfile", "-NonInteractive", "-Command", behaviorContract) + command.Env = append(os.Environ(), "VIIPER_INSTALLER_CONTRACT_PATH="+installer) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("capability host contract failed: %v\n%s", err, output) + } + }) } } - func TestLocalTestBootBoundaryRunsOnWindowsPowerShell51(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows PowerShell contract") diff --git a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 index 29cdda24..71fc7801 100644 --- a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 +++ b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 @@ -83,6 +83,24 @@ function Assert-ExactDirectoryEntries { } } +function Get-LocalTestFileSystemSecurity { + param( + [Parameter(Mandatory = $true)][IO.FileSystemInfo]$Item, + [Parameter(Mandatory = $true)] + [Security.AccessControl.AccessControlSections]$Sections + ) + + if ($null -ne $Item.PSObject.Methods['GetAccessControl']) { + return $Item.GetAccessControl($Sections) + } + if ($Item -is [IO.DirectoryInfo]) { + return [IO.FileSystemAclExtensions]::GetAccessControl( + [IO.DirectoryInfo]$Item, $Sections) + } + return [IO.FileSystemAclExtensions]::GetAccessControl( + [IO.FileInfo]$Item, $Sections) +} + function Assert-ProtectedStagingDirectory { param([Parameter(Mandatory = $true)][string]$Path) @@ -91,7 +109,7 @@ function Assert-ProtectedStagingDirectory { ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Local-test staging directory is missing, not a directory, or a reparse point: '$Path'." } - $actualSecurity = $directory.GetAccessControl( + $actualSecurity = Get-LocalTestFileSystemSecurity -Item $directory -Sections ( [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Access) if (-not $actualSecurity.AreAccessRulesProtected) { @@ -127,6 +145,403 @@ function Assert-ProtectedStagingDirectory { } } +if (-not ('ViiperLocalTestStagingNative' -as [type])) { + Add-Type -Language CSharp -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public sealed class ViiperLocalTestProtectedFile +{ + public SafeFileHandle Handle { get; private set; } + public bool Created { get; private set; } + + public ViiperLocalTestProtectedFile(SafeFileHandle handle, bool created) + { + Handle = handle; + Created = created; + } +} + +public static class ViiperLocalTestStagingNative +{ + private const uint SDDL_REVISION_1 = 1; + private const int ERROR_FILE_EXISTS = 80; + private const int ERROR_ALREADY_EXISTS = 183; + private const uint GENERIC_READ = 0x80000000; + private const uint GENERIC_WRITE = 0x40000000; + private const uint READ_CONTROL = 0x00020000; + private const uint FILE_READ_ATTRIBUTES = 0x00000080; + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint CREATE_NEW = 1; + private const uint OPEN_EXISTING = 3; + private const uint FILE_ATTRIBUTE_NORMAL = 0x00000080; + private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + private const uint FILE_FLAG_WRITE_THROUGH = 0x80000000; + + [StructLayout(LayoutKind.Sequential)] + private struct SecurityAttributes + { + public int Length; + public IntPtr SecurityDescriptor; + [MarshalAs(UnmanagedType.Bool)] public bool InheritHandle; + } + + [StructLayout(LayoutKind.Sequential)] + private struct FileTime + { + public uint Low; + public uint High; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ByHandleFileInformation + { + public uint FileAttributes; + public FileTime CreationTime; + public FileTime LastAccessTime; + public FileTime LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("advapi32.dll", EntryPoint = "ConvertStringSecurityDescriptorToSecurityDescriptorW", + CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ConvertStringSecurityDescriptorToSecurityDescriptor( + string securityDescriptor, uint revision, out IntPtr descriptor, out uint descriptorLength); + + [DllImport("kernel32.dll", EntryPoint = "CreateDirectoryW", CharSet = CharSet.Unicode, + ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateDirectory(string path, ref SecurityAttributes attributes); + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, + ExactSpelling = true, SetLastError = true)] + private static extern SafeFileHandle CreateFileWithSecurity( + string path, uint desiredAccess, uint shareMode, ref SecurityAttributes attributes, + uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile); + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, + ExactSpelling = true, SetLastError = true)] + private static extern SafeFileHandle CreateFileWithoutSecurity( + string path, uint desiredAccess, uint shareMode, IntPtr attributes, + uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile); + + [DllImport("kernel32.dll", ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out ByHandleFileInformation information); + + [DllImport("kernel32.dll", ExactSpelling = true)] + private static extern IntPtr LocalFree(IntPtr memory); + + private static SecurityAttributes ConvertSecurityDescriptor(string sddl, out IntPtr descriptor) + { + uint descriptorLength; + if (!ConvertStringSecurityDescriptorToSecurityDescriptor( + sddl, SDDL_REVISION_1, out descriptor, out descriptorLength)) + throw new Win32Exception(Marshal.GetLastWin32Error(), + "ConvertStringSecurityDescriptorToSecurityDescriptorW"); + return new SecurityAttributes { + Length = Marshal.SizeOf(typeof(SecurityAttributes)), + SecurityDescriptor = descriptor, + InheritHandle = false + }; + } + + public static bool CreateDirectoryExact(string path, string sddl) + { + IntPtr descriptor = IntPtr.Zero; + try + { + SecurityAttributes attributes = ConvertSecurityDescriptor(sddl, out descriptor); + if (CreateDirectory(path, ref attributes)) return true; + int error = Marshal.GetLastWin32Error(); + if (error == ERROR_ALREADY_EXISTS) return false; + throw new Win32Exception(error, "CreateDirectoryW"); + } + finally + { + if (descriptor != IntPtr.Zero) LocalFree(descriptor); + } + } + + public static SafeFileHandle OpenDirectory(string path) + { + SafeFileHandle handle = CreateFileWithoutSecurity( + path, READ_CONTROL | FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero); + if (handle.IsInvalid) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error, "CreateFileW(directory)"); + } + return handle; + } + + public static ViiperLocalTestProtectedFile OpenOrCreateFileExact( + string path, string sddl) + { + IntPtr descriptor = IntPtr.Zero; + try + { + SecurityAttributes attributes = ConvertSecurityDescriptor(sddl, out descriptor); + SafeFileHandle handle = CreateFileWithSecurity( + path, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + ref attributes, CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH, + IntPtr.Zero); + if (!handle.IsInvalid) return new ViiperLocalTestProtectedFile(handle, true); + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + if (error != ERROR_FILE_EXISTS && error != ERROR_ALREADY_EXISTS) + throw new Win32Exception(error, "CreateFileW(CREATE_NEW)"); + + handle = CreateFileWithoutSecurity( + path, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH, + IntPtr.Zero); + if (handle.IsInvalid) + { + error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error, "CreateFileW(OPEN_EXISTING)"); + } + return new ViiperLocalTestProtectedFile(handle, false); + } + finally + { + if (descriptor != IntPtr.Zero) LocalFree(descriptor); + } + } + + public static SafeFileHandle OpenFileReadOnly(string path) + { + SafeFileHandle handle = CreateFileWithoutSecurity( + path, GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, + IntPtr.Zero, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero); + if (handle.IsInvalid) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error, "CreateFileW(read-only)"); + } + return handle; + } + + public static uint LinkCount(SafeFileHandle handle) + { + ByHandleFileInformation information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error(), + "GetFileInformationByHandle"); + return information.NumberOfLinks; + } + +} +'@ +} + +function Assert-ExactLocalTestStagingSecurity { + param( + [Parameter(Mandatory = $true)][IO.FileSystemInfo]$Item, + [Parameter(Mandatory = $true)][bool]$Directory + ) + + if ($Item.PSIsContainer -ne $Directory -or + ($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "The local-test protected staging path has an unsafe object type: '$($Item.FullName)'." + } + $security = Get-LocalTestFileSystemSecurity -Item $Item -Sections ( + [Security.AccessControl.AccessControlSections]::Owner -bor + [Security.AccessControl.AccessControlSections]::Group -bor + [Security.AccessControl.AccessControlSections]::Access) + if (-not $security.AreAccessRulesProtected -or + $security.GetOwner([Security.Principal.SecurityIdentifier]).Value -cne 'S-1-5-32-544' -or + $security.GetGroup([Security.Principal.SecurityIdentifier]).Value -cne 'S-1-5-32-544') { + throw "The local-test protected staging object has an unsafe owner, group, or inherited DACL: '$($Item.FullName)'." + } + $rules = @($security.GetAccessRules( + $true, $true, [Security.Principal.SecurityIdentifier])) + if ($rules.Count -ne 2) { + throw "The local-test protected staging object has an unexpected access-rule count: '$($Item.FullName)'." + } + $expectedInheritance = if ($Directory) { + [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor + [Security.AccessControl.InheritanceFlags]::ObjectInherit + } + else { + [Security.AccessControl.InheritanceFlags]::None + } + foreach ($expectedSID in @('S-1-5-18', 'S-1-5-32-544')) { + $matches = @($rules | Where-Object { + $_.IdentityReference.Value -ceq $expectedSID + }) + if ($matches.Count -ne 1) { + throw "The local-test protected staging object is missing an exact protected principal: '$($Item.FullName)'." + } + $rule = $matches[0] + if ($rule.IsInherited -or + $rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + $rule.FileSystemRights -ne [Security.AccessControl.FileSystemRights]::FullControl -or + $rule.InheritanceFlags -ne $expectedInheritance -or + $rule.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None) { + throw "The local-test protected staging object has an unexpected access rule: '$($Item.FullName)'." + } + } +} + +function New-LocalTestTrustCapability { + param( + [Parameter(Mandatory = $true)][string]$StageDirectory, + [Parameter(Mandatory = $true)][string]$SourceRevision, + [Parameter(Mandatory = $true)][string]$CertificatePath, + [Parameter(Mandatory = $true)][string]$CertificateSHA256, + [Parameter(Mandatory = $true)][string]$PackageLockSHA256, + [Parameter(Mandatory = $true)][string]$TrustJournalDirectory + ) + + $path = Join-Path $StageDirectory 'local-test-trust-capability.json' + if ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($path)) -cne + [IO.Path]::GetFullPath($StageDirectory).TrimEnd( + [IO.Path]::DirectorySeparatorChar)) { + throw 'The local-test trust capability escaped its protected broker stage.' + } + $nonceBytes = [byte[]]::new(16) + $random = [Security.Cryptography.RandomNumberGenerator]::Create() + try { + $random.GetBytes($nonceBytes) + $nonce = ([BitConverter]::ToString($nonceBytes)).Replace('-', '').ToLowerInvariant() + } + finally { + $random.Dispose() + [Array]::Clear($nonceBytes, 0, $nonceBytes.Length) + } + $currentProcess = [Diagnostics.Process]::GetCurrentProcess() + try { + $parentPID = [uint32]$currentProcess.Id + $parentCreationFileTime = [uint64]$currentProcess.StartTime.ToUniversalTime().ToFileTimeUtc() + } + finally { + $currentProcess.Dispose() + } + $payload = [ordered]@{ + schema = 'viiper.native.local-test-trust-capability/v1' + nonce = $nonce + parentPid = $parentPID + parentCreationFileTime = $parentCreationFileTime + sourceRevision = $SourceRevision + certificatePath = [IO.Path]::GetFullPath($CertificatePath) + certificateSha256 = $CertificateSHA256 + packageLockSha256 = $PackageLockSHA256 + trustJournalSchema = 'viiper.native.local-test-trust-ownership/v1' + trustJournalDirectory = [IO.Path]::GetFullPath($TrustJournalDirectory) + } + $json = $payload | ConvertTo-Json -Compress -Depth 2 + if ($json.IndexOf("`r") -ge 0 -or $json.IndexOf("`n") -ge 0) { + throw 'The local-test trust capability serializer emitted noncanonical framing.' + } + $bytes = [Text.UTF8Encoding]::new($false, $true).GetBytes($json) + if ($bytes.Length -eq 0 -or $bytes.Length -gt 4096) { + throw 'The local-test trust capability exceeds its exact size bound.' + } + $opened = [ViiperLocalTestStagingNative]::OpenOrCreateFileExact( + $path, 'O:BAG:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)') + if (-not $opened.Created) { + $opened.Handle.Dispose() + throw 'Refusing to reuse a local-test trust capability file.' + } + $writeStream = $null + try { + $writeStream = [IO.FileStream]::new( + $opened.Handle, [IO.FileAccess]::ReadWrite, 4096, $false) + $writeStream.Write($bytes, 0, $bytes.Length) + $writeStream.Flush($true) + } + finally { + if ($null -ne $writeStream) { + $writeStream.Dispose() + } + else { + $opened.Handle.Dispose() + } + } + + $readHandle = $null + $readStream = $null + try { + $readHandle = [ViiperLocalTestStagingNative]::OpenFileReadOnly($path) + $readStream = [IO.FileStream]::new( + $readHandle, [IO.FileAccess]::Read, 4096, $false) + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + Assert-ExactLocalTestStagingSecurity -Item $item -Directory $false + if ([ViiperLocalTestStagingNative]::LinkCount($readHandle) -ne 1 -or + $readStream.Length -ne $bytes.Length) { + throw 'The sealed local-test trust capability has an invalid identity or length.' + } + $algorithm = [Security.Cryptography.SHA256]::Create() + try { + $sha256 = ([BitConverter]::ToString( + $algorithm.ComputeHash($readStream))).Replace('-', '').ToLowerInvariant() + } + finally { + $algorithm.Dispose() + } + $readStream.Position = 0 + return [pscustomobject]@{ + Path = $path + SHA256 = $sha256 + Stream = $readStream + } + } + catch { + if ($null -ne $readStream) { + $readStream.Dispose() + } + elseif ($null -ne $readHandle) { + $readHandle.Dispose() + } + try { [IO.File]::Delete($path) } catch { } + throw + } +} + +function Remove-LocalTestTrustCapability { + param( + [Parameter(Mandatory = $true)]$Capability, + [Parameter(Mandatory = $true)][string]$StageDirectory + ) + + if ($null -ne $Capability.Stream) { + $Capability.Stream.Dispose() + $Capability.Stream = $null + } + $path = [IO.Path]::GetFullPath([string]$Capability.Path) + if ([IO.Path]::GetDirectoryName($path) -cne + [IO.Path]::GetFullPath($StageDirectory).TrimEnd( + [IO.Path]::DirectorySeparatorChar) -or + [IO.Path]::GetFileName($path) -cne 'local-test-trust-capability.json') { + throw 'Refusing unsafe local-test trust capability cleanup.' + } + if (Test-Path -LiteralPath $path) { + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + Assert-ExactLocalTestStagingSecurity -Item $item -Directory $false + [IO.File]::Delete($path) + } +} + function Initialize-ProtectedStagingDirectory { param([Parameter(Mandatory = $true)][string]$Path) @@ -216,14 +631,16 @@ function Remove-ProtectedStagingDirectory { } Assert-ProtectedStagingDirectory -Path $fullPath $children = @(Get-ChildItem -LiteralPath $fullPath -Force) - if ($children.Count -gt 1 -or - ($children.Count -eq 1 -and - ($children[0].Name -cne 'viiper.exe' -or $children[0].PSIsContainer -or - ($children[0].Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0))) { + $allowedChildren = @('viiper.exe', 'local-test-trust-capability.json') + if ($children.Count -gt $allowedChildren.Count -or + @($children | Where-Object { + $allowedChildren -cnotcontains $_.Name -or $_.PSIsContainer -or + ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 + }).Count -ne 0) { throw "Refusing local-test staging cleanup with unexpected entries in '$Path'." } - if ($children.Count -eq 1) { - [IO.File]::Delete($children[0].FullName) + foreach ($child in $children) { + [IO.File]::Delete($child.FullName) } [IO.Directory]::Delete($fullPath, $false) } @@ -533,294 +950,39 @@ if ($PreflightOnly) { } } -$certificateThumbprint = $certificate.Thumbprint -$expectedCertificateBytes = [Convert]::ToBase64String($certificate.RawData) -if (-not ('ViiperLocalTestCertificateStore' -as [type])) { - Add-Type -Language CSharp -TypeDefinition @' -using System; -using System.ComponentModel; -using System.Runtime.InteropServices; - -public static class ViiperLocalTestCertificateStore -{ - private const int CERT_STORE_PROV_SYSTEM_W = 10; - private const uint CERT_SYSTEM_STORE_LOCAL_MACHINE = 0x00020000; - private const uint CERT_STORE_OPEN_EXISTING_FLAG = 0x00004000; - private const uint CERT_STORE_MAXIMUM_ALLOWED_FLAG = 0x00001000; - private const uint CERT_ENCODING = 0x00010001; - private const uint CERT_STORE_ADD_NEW = 1; - private const uint CERT_FIND_EXISTING = 0x000d0000; - private const int CRYPT_E_NOT_FOUND = unchecked((int)0x80092004); - - [DllImport("crypt32.dll", CharSet = CharSet.Unicode, SetLastError = true, - ExactSpelling = true)] - private static extern IntPtr CertOpenStore( - IntPtr provider, uint encoding, IntPtr cryptProvider, - uint flags, string storeName); - - [DllImport("crypt32.dll", SetLastError = true)] - private static extern bool CertAddEncodedCertificateToStore( - IntPtr store, uint encoding, byte[] certificate, uint length, - uint disposition, out IntPtr context); - - [DllImport("crypt32.dll", SetLastError = true)] - private static extern IntPtr CertCreateCertificateContext( - uint encoding, byte[] certificate, uint length); - - [DllImport("crypt32.dll", SetLastError = true)] - private static extern IntPtr CertFindCertificateInStore( - IntPtr store, uint encoding, uint findFlags, uint findType, - IntPtr findParameter, IntPtr previousContext); - - [DllImport("crypt32.dll", SetLastError = true)] - private static extern bool CertDeleteCertificateFromStore(IntPtr context); - - [DllImport("crypt32.dll")] - private static extern bool CertFreeCertificateContext(IntPtr context); - - [DllImport("crypt32.dll", SetLastError = true)] - private static extern bool CertCloseStore(IntPtr store, uint flags); - - private static IntPtr Open(string storeName) - { - IntPtr store = CertOpenStore( - new IntPtr(CERT_STORE_PROV_SYSTEM_W), 0, IntPtr.Zero, - CERT_SYSTEM_STORE_LOCAL_MACHINE | CERT_STORE_OPEN_EXISTING_FLAG | - CERT_STORE_MAXIMUM_ALLOWED_FLAG, - storeName); - if (store == IntPtr.Zero) - throw new Win32Exception(Marshal.GetLastWin32Error(), "CertOpenStore"); - return store; - } - - public static void Add(string storeName, byte[] certificate) - { - IntPtr store = Open(storeName); - IntPtr context = IntPtr.Zero; - try - { - if (!CertAddEncodedCertificateToStore( - store, CERT_ENCODING, certificate, (uint)certificate.Length, - CERT_STORE_ADD_NEW, out context)) - throw new Win32Exception( - Marshal.GetLastWin32Error(), "CertAddEncodedCertificateToStore"); - } - finally - { - if (context != IntPtr.Zero) CertFreeCertificateContext(context); - CertCloseStore(store, 0); - } - } - - public static bool Remove(string storeName, byte[] certificate) - { - IntPtr store = Open(storeName); - IntPtr search = IntPtr.Zero; - try - { - search = CertCreateCertificateContext( - CERT_ENCODING, certificate, (uint)certificate.Length); - if (search == IntPtr.Zero) - throw new Win32Exception( - Marshal.GetLastWin32Error(), "CertCreateCertificateContext"); - IntPtr found = CertFindCertificateInStore( - store, CERT_ENCODING, 0, CERT_FIND_EXISTING, search, IntPtr.Zero); - if (found == IntPtr.Zero) - { - int error = Marshal.GetLastWin32Error(); - if (error == CRYPT_E_NOT_FOUND) return false; - throw new Win32Exception(error, "CertFindCertificateInStore"); - } - if (!CertDeleteCertificateFromStore(found)) - throw new Win32Exception( - Marshal.GetLastWin32Error(), "CertDeleteCertificateFromStore"); - return true; - } - finally - { - if (search != IntPtr.Zero) CertFreeCertificateContext(search); - CertCloseStore(store, 0); - } - } -} -'@ -} - -$certificateStoreOpenMethod = [ViiperLocalTestCertificateStore].GetMethod( - 'CertOpenStore', [Reflection.BindingFlags]'NonPublic,Static') -$certificateStoreOpenImport = $certificateStoreOpenMethod.GetCustomAttributes( - [Runtime.InteropServices.DllImportAttribute], $false)[0] -if ($certificateStoreOpenImport.Value -cne 'crypt32.dll' -or - -not $certificateStoreOpenImport.ExactSpelling -or - $certificateStoreOpenImport.CharSet -ne [Runtime.InteropServices.CharSet]::Unicode) { - throw 'The local-test certificate-store interop does not bind the exact CertOpenStore entry point.' -} - if ($PreflightOnly) { + $certificate.Dispose() Write-Output 'result=success operation=local-test-preflight changed=0 rebootRequired=0 rollback=not-needed exitCode=0' return } -function Get-ExactLocalTestTrustState { - param([Parameter(Mandatory = $true)][string]$StoreName) - - $store = [Security.Cryptography.X509Certificates.X509Store]::new( - $StoreName, [Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) - $matches = $null - try { - # Reopening the store read-only makes every verification a persisted-state - # postcondition rather than an observation through the mutating handle. - $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) - $matches = $store.Certificates.Find( - [Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, - $certificateThumbprint, $false) - $exactMatches = @($matches | Where-Object { - [Convert]::ToBase64String($_.RawData) -ceq $expectedCertificateBytes - }) - if ($matches.Count -ne $exactMatches.Count -or $exactMatches.Count -gt 1) { - throw "Certificate collision in LocalMachine\$StoreName." - } - return [pscustomobject]@{ ExactCount = [int]$exactMatches.Count } - } - finally { - if ($null -ne $matches) { - foreach ($match in $matches) { - $match.Dispose() - } - } - $store.Close() +foreach ($name in @('ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.cat')) { + $runtime = Join-Path $driverDirectory $name + $evidence = Join-Path $signedPackage $name + if ((Get-FileHash -LiteralPath $runtime -Algorithm SHA256).Hash -cne + (Get-FileHash -LiteralPath $evidence -Algorithm SHA256).Hash) { + throw "Runtime driver file '$name' differs from its validated evidence copy." } } -$addedStores = [Collections.Generic.List[string]]::new() -function Remove-NewLocalTestTrust { - $removalErrors = [Collections.Generic.List[Exception]]::new() - foreach ($storeName in $addedStores) { - $cleanupAction = 'inspect-cleanup' - try { - $cleanupState = Get-ExactLocalTestTrustState -StoreName $storeName - $cleanupAction = 'remove' - if ($cleanupState.ExactCount -eq 1) { - $removed = [ViiperLocalTestCertificateStore]::Remove( - $storeName, $certificate.RawData) - $removeResult = if ($removed) { 'removed' } else { 'already-absent' } - } - else { - $removeResult = 'already-absent' - } - Write-Host "local-test-trust store=$storeName action=remove result=$removeResult" +$manifestHash = [string]($lockByPath['submission-manifest.json'].sha256) +$infHash = [string]($lockByPath['driver/ViiperUde.inf'].sha256) +$sysHash = [string]($lockByPath['driver/ViiperUde.sys'].sha256) +$catHash = [string]($lockByPath['driver/ViiperUde.cat'].sha256) +$brokerEntry = $lockByPath['viiper.exe'] +$brokerHash = [string]$brokerEntry.sha256 +$helperHash = [string]($lockByPath['ViiperUdeCtl.exe'].sha256) - $cleanupAction = 'verify-cleanup' - $cleanupState = Get-ExactLocalTestTrustState -StoreName $storeName - if ($cleanupState.ExactCount -ne 0) { - throw "Exact local-test certificate remained in LocalMachine\$storeName." - } - Write-Host "local-test-trust store=$storeName action=verify-cleanup result=absent" - } - catch { - Write-Host "local-test-trust store=$storeName action=$cleanupAction result=error" - $removalErrors.Add([InvalidOperationException]::new( - "LocalMachine\$storeName trust cleanup failed during $cleanupAction.", - $_.Exception)) - } - } - if ($removalErrors.Count -ne 0) { - throw [AggregateException]::new( - 'Failed to remove one or more local-test trust anchors after a settled failure.', - [Exception[]]$removalErrors.ToArray()) - } +$programDataRoot = (Resolve-Path -LiteralPath $env:ProgramData -ErrorAction Stop).Path +$programDataItem = Get-Item -LiteralPath $programDataRoot -Force -ErrorAction Stop +if (-not $programDataItem.PSIsContainer -or + ($programDataItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "ProgramData is not a safe staging parent: '$programDataRoot'." } - -function Test-SettledLocalTestFailure { - param( - [Parameter(Mandatory = $true)][object[]]$Lines, - [Parameter(Mandatory = $true)][int]$ProcessExitCode - ) - - $pattern = '(?m)^result=error operation=install changed=(?[01]) ' + - 'rebootRequired=(?[01]) rollback=(?not-needed|succeeded|failed) ' + - 'exitCode=(?[0-9]+)(?: .*)?\r?$' - # Out-String formats through the host and wraps long native proof lines at - # the current console width. Preserve the already-delimited child output - # byte-for-line instead: diagnostics may make the canonical proof much - # wider than the host while the rollback fields remain authoritative. - $proofText = [string]::Join([Environment]::NewLine, [string[]]$Lines) - $matches = [regex]::Matches($proofText, $pattern) - if ($matches.Count -ne 1) { - return $false - } - $match = $matches[0] - $proofExitCode = 0 - if (-not [int]::TryParse($match.Groups['exit'].Value, [ref]$proofExitCode) -or - $proofExitCode -ne $ProcessExitCode) { - return $false - } - return ($match.Groups['changed'].Value -ceq '0' -and - $match.Groups['reboot'].Value -ceq '0' -and - $match.Groups['rollback'].Value -ceq 'not-needed' -and - $proofExitCode -in @(1, 4)) -or - ($match.Groups['changed'].Value -ceq '1' -and - $match.Groups['reboot'].Value -ceq '0' -and - $match.Groups['rollback'].Value -ceq 'succeeded' -and - $proofExitCode -eq 1) -} - -$trustCommitted = $false -$retainTrustOnFailure = $false +$trustJournalDirectory = Join-Path $programDataRoot 'VIIPER-TrustManager' $stageDirectory = $null -$programDataRoot = $null +$trustCapability = $null try { - foreach ($storeName in @('Root', 'TrustedPublisher')) { - $trustAction = 'inspect-add' - try { - $trustState = Get-ExactLocalTestTrustState -StoreName $storeName - if ($trustState.ExactCount -eq 0) { - $trustAction = 'add' - [ViiperLocalTestCertificateStore]::Add( - $storeName, $certificate.RawData) - $addedStores.Add($storeName) - Write-Host "local-test-trust store=$storeName action=add result=added" - - $trustAction = 'verify-add' - $trustState = Get-ExactLocalTestTrustState -StoreName $storeName - if ($trustState.ExactCount -ne 1) { - throw "Exact local-test certificate was not installed in LocalMachine\$storeName." - } - Write-Host "local-test-trust store=$storeName action=verify-add result=present" - } - else { - Write-Host "local-test-trust store=$storeName action=add result=preexisting" - } - } - catch { - Write-Host "local-test-trust store=$storeName action=$trustAction result=error" - throw - } - } - - foreach ($name in @('ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.cat')) { - $runtime = Join-Path $driverDirectory $name - $evidence = Join-Path $signedPackage $name - if ((Get-FileHash -LiteralPath $runtime -Algorithm SHA256).Hash -cne - (Get-FileHash -LiteralPath $evidence -Algorithm SHA256).Hash) { - throw "Runtime driver file '$name' differs from its validated evidence copy." - } - } - - $manifestHash = [string]($lockByPath['submission-manifest.json'].sha256) - $infHash = [string]($lockByPath['driver/ViiperUde.inf'].sha256) - $sysHash = [string]($lockByPath['driver/ViiperUde.sys'].sha256) - $catHash = [string]($lockByPath['driver/ViiperUde.cat'].sha256) - $brokerEntry = $lockByPath['viiper.exe'] - $brokerHash = [string]$brokerEntry.sha256 - $helperHash = [string]($lockByPath['ViiperUdeCtl.exe'].sha256) - - $programDataRoot = (Resolve-Path -LiteralPath $env:ProgramData -ErrorAction Stop).Path - $programDataItem = Get-Item -LiteralPath $programDataRoot -Force -ErrorAction Stop - if (-not $programDataItem.PSIsContainer -or - ($programDataItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "ProgramData is not a safe staging parent: '$programDataRoot'." - } Remove-PreBootProtectedStagingDirectories -ProgramDataRoot $programDataRoot $stageDirectory = Join-Path $programDataRoot ( 'VIIPER.LocalTestStage.' + [Guid]::NewGuid().ToString('N')) @@ -828,10 +990,12 @@ try { $brokerPath = Copy-ExactBrokerToProtectedStage ` -SourcePath $packageBrokerPath -DestinationDirectory $stageDirectory ` -ExpectedLength ([long]$brokerEntry.length) -ExpectedSHA256 $brokerHash + $trustCapability = New-LocalTestTrustCapability ` + -StageDirectory $stageDirectory -SourceRevision $source ` + -CertificatePath $certificatePath -CertificateSHA256 $certificateSha256 ` + -PackageLockSHA256 $actualPackageLockSha256 ` + -TrustJournalDirectory $trustJournalDirectory - $output = @() - $exitCode = $null - $launchError = $null $processStarted = $false $brokerArguments = @( 'native-package-install', @@ -846,67 +1010,67 @@ try { '--expected-sys-sha-256', $sysHash, '--expected-cat-sha-256', $catHash, '--target-user-sid', $TargetUserSID, - '--driver-validation-mode', 'local-test' + '--driver-validation-mode', 'local-test', + '--local-test-trust-capability', $trustCapability.Path, + '--expected-trust-capability-sha-256', $trustCapability.SHA256, + '--local-test-certificate-path', $certificatePath, + '--expected-local-test-certificate-sha-256', $certificateSha256, + '--expected-local-test-package-lock-sha-256', $actualPackageLockSha256 ) - try { - $processResult = Invoke-JoinedNativeProcess ` - -FileName $brokerPath -Arguments $brokerArguments ` - -WorkingDirectory $stageDirectory -Started ([ref]$processStarted) - $retainTrustOnFailure = $processStarted - $exitCode = [int]$processResult.ExitCode - $output = @($processResult.Output) - } - catch { - $retainTrustOnFailure = $processStarted - $launchError = $_ - } - $output | ForEach-Object { Write-Host $_ } - if ($null -ne $exitCode) { - if ($exitCode -in @(0, 3010)) { - $trustCommitted = $true - } - elseif (Test-SettledLocalTestFailure ` - -Lines $output -ProcessExitCode $exitCode) { - $retainTrustOnFailure = $false - } - } + $processResult = Invoke-JoinedNativeProcess ` + -FileName $brokerPath -Arguments $brokerArguments ` + -WorkingDirectory $stageDirectory -Started ([ref]$processStarted) + $processResult.Output | ForEach-Object { Write-Host $_ } + $exitCode = [int]$processResult.ExitCode + + Remove-LocalTestTrustCapability ` + -Capability $trustCapability -StageDirectory $stageDirectory + $trustCapability = $null Remove-ProtectedStagingDirectory ` -Path $stageDirectory -ProgramDataRoot $programDataRoot $stageDirectory = $null - if ($null -ne $launchError) { - throw $launchError - } + if ($exitCode -notin @(0, 3010)) { throw "Local VIIPER driver transaction failed with exit code $exitCode." } if ($exitCode -eq 3010) { - Write-Warning 'The native transaction stopped at a safe reboot boundary before mutation or after successful rollback. Restart, rerun this identical install command before creating another virtual device, and proceed to live validation only after it returns exit 0.' + Write-Warning 'The native transaction retained its durable trust/package authority at a reboot or indeterminate boundary. Restart and rerun this identical install command; do not remove certificates or journal files manually.' exit 3010 } } catch { $failure = $_ - $cleanupFailure = $null - if ($null -ne $stageDirectory -and $null -ne $programDataRoot) { + $cleanupFailures = [Collections.Generic.List[Exception]]::new() + if ($null -ne $trustCapability -and $null -ne $stageDirectory) { + try { + Remove-LocalTestTrustCapability ` + -Capability $trustCapability -StageDirectory $stageDirectory + $trustCapability = $null + } + catch { + $cleanupFailures.Add($_.Exception) + } + } + if ($null -ne $stageDirectory) { try { Remove-ProtectedStagingDirectory ` -Path $stageDirectory -ProgramDataRoot $programDataRoot $stageDirectory = $null } catch { - $cleanupFailure = $_ + $cleanupFailures.Add($_.Exception) } } - if (-not $trustCommitted -and -not $retainTrustOnFailure) { - Remove-NewLocalTestTrust - } - if ($null -ne $cleanupFailure) { + if ($cleanupFailures.Count -ne 0) { throw [AggregateException]::new( 'Local VIIPER installation failed and protected staging cleanup also failed.', - [Exception[]]@($failure.Exception, $cleanupFailure.Exception)) + [Exception[]](@($failure.Exception) + $cleanupFailures.ToArray())) } throw $failure } +finally { + $certificate.Dispose() +} Write-Host 'The exact local test-signed VIIPER UdeCx driver and native broker are installed, authenticated, and ready.' Write-Host 'Next: enable Driver Verifier for ViiperUde.sys, reboot, then run Invoke-ViiperUdeLiveValidation.ps1 in LocalTest mode.' diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 index 3204813a..8d807a2b 100644 --- a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -187,7 +187,11 @@ $brokerHelpText = $brokerHelpOutput -join [Environment]::NewLine $expectedBrokerFlags = @( '--expected-broker-sha-256', '--expected-helper-sha-256', '--expected-manifest-sha-256', '--expected-inf-sha-256', - '--expected-sys-sha-256', '--expected-cat-sha-256' + '--expected-sys-sha-256', '--expected-cat-sha-256', + '--local-test-trust-capability', '--expected-trust-capability-sha-256', + '--local-test-certificate-path', + '--expected-local-test-certificate-sha-256', + '--expected-local-test-package-lock-sha-256' ) if ($brokerHelpExitCode -ne 0 -or @($expectedBrokerFlags | Where-Object { diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index 5ec6fe46..762b30e8 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -172,6 +172,15 @@ $requiredContracts = [ordered]@{ 'nested broker expected executable hash option' = '--expected-broker-sha-256' 'cooperative package deadline' = '--transaction-deadline-unix-ms' 'same-handle manifest binding' = 'Sha256Handle\(manifest\.get\(\)' + 'canonical protected broker digest comparison' = + 'bool LockProtectedBrokerImage\([\s\S]{0,2600}!SameCanonicalSha256Digest\(observed, expectedSha256\)' + 'compiled protected broker digest regression' = 'self-test-canonical-sha256' + 'recordless failed-install recovery command' = + 'recover-failed-install-recordless' + 'recordless failed-install recovery implementation' = + 'Outcome RecoverFailedInstallRecordless\(' + 'recordless failed-install active absence proof' = + 'VerifyRecordlessRecoveryActivePathAbsent\(' 'final exact package enumeration' = 'ValidateExactPackageDirectory\(' 'reboot boundary rollback' = 'broker-reboot-boundary' 'fixed remove recovery root' = @@ -259,6 +268,37 @@ foreach ($entry in $requiredContracts.GetEnumerator()) { } } +$recordlessRecovery = Get-SourceContractRegion -Text $source ` + -Start 'Outcome RecoverFailedInstallRecordless(' ` + -End 'enum class InstallJournalRecoveryModelAction' ` + -Name 'recordless failed-install recovery' +Assert-OrderedSourceFragments -Text $recordlessRecovery -Name ` + 'recordless failed-install recovery' -Fragments @( + 'ValidateTransactionDeadlineBudget(', + 'IsElevated()', + 'TransactionMutex mutex;', + 'mutex.Acquire(&outcome.error)', + 'installDirectory.OpenChain(', + 'false, nullptr, &installActive', + 'removeDirectory.OpenChain(false, &removeActive', + 'if (installActive || removeActive)', + 'VerifyRecordlessRecoveryActivePathAbsent(', + 'outcome.success = true;', + 'outcome.changed = false;', + 'outcome.rebootRequired = false;', + 'outcome.rollback = L"not-needed";', + 'outcome.exitCode = ExitCode::Success;' + ) +foreach ($forbidden in @( + 'ReconcileInstallJournal(', 'ReconcileRemoveJournal(', + 'RemoveDevice(', 'DiUninstallDriverW(', 'SetupCopyOEMInfW(', + 'CreateOrOpenInstallRecoveryDirectory(', 'remove_all(', + 'MoveFileExW(', 'DeleteFileW(')) { + if ($recordlessRecovery.Contains($forbidden)) { + throw "Recordless failed-install recovery gained forbidden mutation '$forbidden'." + } +} + $installJournalRequired = [ordered]@{ 'known-folder ProgramData resolution' = 'SHGetKnownFolderPath\(' 'exact ProgramData known-folder identity' = 'FOLDERID_ProgramData' diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index e252f682..f00dea76 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -4608,6 +4608,28 @@ bool IsCanonicalLowerHex(std::string_view value, size_t length) noexcept { }); } +bool SameCanonicalSha256Digest( + std::string_view observed, + std::string_view expected) noexcept { + // Sha256Handle uses the CryptoAPI display convention with uppercase A-F, + // while durable package and journal identities are canonical lowercase. + if (!IsCanonicalLowerHex(expected, 64U) || observed.size() != expected.size()) { + return false; + } + for (size_t index = 0; index < observed.size(); ++index) { + const unsigned char character = + static_cast(observed[index]); + const unsigned char canonical = + character >= 'A' && character <= 'F' + ? static_cast(character + ('a' - 'A')) + : character; + if (canonical != static_cast(expected[index])) { + return false; + } + } + return true; +} + bool ParseBrokerJournalProofLine( const std::string& line, BrokerCommitProof* proof) { @@ -7602,7 +7624,7 @@ bool LockProtectedBrokerImage( error->phase = L"install-journal-broker-image-hash"; return false; } - if (observed != expectedSha256) { + if (!SameCanonicalSha256Digest(observed, expectedSha256)) { return SetError(error, L"install-journal-broker-image-hash", ERROR_CRC, L"protected broker evidence differs from its immutable digest"); @@ -15802,6 +15824,83 @@ Outcome Recover(uint64_t transactionDeadlineUnixMs) { return outcome; } +bool VerifyRecordlessRecoveryActivePathAbsent( + const std::filesystem::path& active, + const wchar_t* phase, + Error* error) { + const DWORD attributes = GetFileAttributesW(active.c_str()); + if (attributes != INVALID_FILE_ATTRIBUTES) { + return SetError(error, phase, ERROR_BUSY, + L"an active journal exists; recordless failed-install verification has no mutation authority"); + } + const DWORD code = GetLastError(); + if (code != ERROR_FILE_NOT_FOUND && code != ERROR_PATH_NOT_FOUND) { + return SetError(error, phase, code, + L"active-journal absence could not be proven"); + } + return true; +} + +// RecoverFailedInstallRecordless is intentionally verify-only. The exact R4 +// digest cut happened before the first durable install record and its +// destructor already removed active-v2. Parent VIIPER/UdeCx skeletons may +// remain, but this operation never retires a directory, calls Reconcile*, or +// mutates services, devices, packages, or trust. Any active install/remove +// journal belongs to a different/current transaction and is a hard stop. +Outcome RecoverFailedInstallRecordless(uint64_t transactionDeadlineUnixMs) { + Outcome outcome; + if (!ValidateTransactionDeadlineBudget( + transactionDeadlineUnixMs, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (!IsElevated()) { + SetError(&outcome.error, L"elevation", ERROR_ELEVATION_REQUIRED); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + TransactionMutex mutex; + if (!mutex.Acquire(&outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + InstallRecoveryDirectory installDirectory; + bool installActive = false; + if (!installDirectory.OpenChain( + false, nullptr, &installActive, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + RemoveRecoveryDirectory removeDirectory; + bool removeActive = false; + if (!removeDirectory.OpenChain(false, &removeActive, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (installActive || removeActive) { + SetError(&outcome.error, L"failed-install-recordless-active-journal", + ERROR_BUSY, + L"an active install/remove journal is outside the exact recordless R4 failure authority"); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (!VerifyRecordlessRecoveryActivePathAbsent( + installDirectory.active, + L"failed-install-recordless-install-postcheck", &outcome.error) || + !VerifyRecordlessRecoveryActivePathAbsent( + removeDirectory.active, + L"failed-install-recordless-remove-postcheck", &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + outcome.success = true; + outcome.changed = false; + outcome.rebootRequired = false; + outcome.rollback = L"not-needed"; + outcome.exitCode = ExitCode::Success; + return outcome; +} + enum class InstallJournalRecoveryModelAction { RetirePrior, RetireForward, @@ -17659,6 +17758,23 @@ Outcome SelfTest() { !RunRemoveJournalModelSelfTest(&outcome.error)) { return outcome; } + const std::string canonicalSha256 = + "0123456789abcdef0123456789abcdef" + "0123456789abcdef0123456789abcdef"; + const std::string uppercaseSha256 = + "0123456789ABCDEF0123456789ABCDEF" + "0123456789ABCDEF0123456789ABCDEF"; + std::string differentSha256 = canonicalSha256; + differentSha256.back() = '0'; + if (!SameCanonicalSha256Digest(canonicalSha256, canonicalSha256) || + !SameCanonicalSha256Digest(uppercaseSha256, canonicalSha256) || + SameCanonicalSha256Digest(differentSha256, canonicalSha256) || + SameCanonicalSha256Digest(canonicalSha256, uppercaseSha256)) { + SetError(&outcome.error, L"self-test-canonical-sha256", + ERROR_INVALID_DATA, + L"protected evidence hashes do not use exact canonical SHA-256 comparison"); + return outcome; + } InstallOptions brokerCommandOptions; brokerCommandOptions.brokerExecutable = LR"(C:\Program Files\VIIPER\viiper.exe)"; brokerCommandOptions.brokerToken = LR"(C:\ProgramData\VIIPER\package.token)"; @@ -18766,6 +18882,7 @@ void Usage() { L"--transaction-deadline-unix-ms \n" << L" ViiperUdeCtl.exe remove [--transaction-deadline-unix-ms ]\n" << L" ViiperUdeCtl.exe recover [--transaction-deadline-unix-ms ]\n" + << L" ViiperUdeCtl.exe recover-failed-install-recordless [--transaction-deadline-unix-ms ]\n" << L" ViiperUdeCtl.exe broker-settlement-ack --request --request-sha256 <64 hex> --transaction-deadline-unix-ms \n" << L" ViiperUdeCtl.exe broker-settlement-discard --broker-transaction-id <32 hex> --broker-settled-digest <64 hex> --driver-transaction-id <64 hex> --driver-settled-digest <64 hex> --settlement-nonce <64 hex> --request-sha256 <64 hex> --broker-final-receipt --broker-final-receipt-sha256 <64 hex> --transaction-deadline-unix-ms \n" << L" ViiperUdeCtl.exe status\n" @@ -18890,6 +19007,23 @@ int RunViiperUdeCtl(int argc, wchar_t** argv) { EmitOutcome(L"recover", outcome); return static_cast(outcome.exitCode); } + if (argc >= 2 && + _wcsicmp(argv[1], L"recover-failed-install-recordless") == 0) { + RemoveOptions options; + Error argumentError; + if (!ParseRemoveOptions(argc, argv, &options, &argumentError)) { + Usage(); + Outcome outcome; + outcome.error = std::move(argumentError); + outcome.exitCode = ExitCode::Usage; + EmitOutcome(L"recover-failed-install-recordless", outcome); + return static_cast(outcome.exitCode); + } + Outcome outcome = RecoverFailedInstallRecordless( + options.transactionDeadlineUnixMs); + EmitOutcome(L"recover-failed-install-recordless", outcome); + return static_cast(outcome.exitCode); + } if (argc == 2 && _wcsicmp(argv[1], L"status") == 0) { Outcome outcome = Status(); EmitOutcome(L"status", outcome); @@ -18915,7 +19049,8 @@ const wchar_t* ExceptionOperation(int argc, wchar_t** argv) noexcept { for (const wchar_t* operation : {L"install", L"verify", L"remove", L"recover", L"status", L"self-test", L"broker-settlement-ack", - L"broker-settlement-discard"}) { + L"broker-settlement-discard", + L"recover-failed-install-recordless"}) { if (_wcsicmp(argv[1], operation) == 0) { return operation; } From 2da61e45d34eb2775e45010ac97fb30a22d9bd8e Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 16 Aug 2026 13:10:49 -0500 Subject: [PATCH 4/4] fix: harden native package receipt validation --- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 269 ++++++ native/udecx/tools/ViiperUdeCtl.cpp | 850 ++++++++++++++++-- 2 files changed, 1028 insertions(+), 91 deletions(-) diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index 762b30e8..1555f1fc 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -72,6 +72,16 @@ $requiredContracts = [ordered]@{ 'signed-certificate EKU extension only' = 'CERT_FIND_EXT_ONLY_ENHKEY_USAGE_FLAG' 'published INF capture' = 'SetupGetInfPublishedNameW\(' 'driver-store source capture' = 'SetupGetInfDriverStoreLocationW\(' + 'shared-system Windows directory identity' = 'GetSystemWindowsDirectoryW\(' + 'canonical package digest comparison' = 'SameCanonicalPackageDigest\(' + 'pure published INF normalization' = 'NormalizePublishedInfPath\(' + 'published INF local-file validation' = 'ValidatePublishedInfPath\(' + 'compiled published INF normalization regressions' = + 'self-test-published-inf-normalization' + 'read-only live SetupAPI round trip' = + 'RunPublishedInfRoundTripLiveSelfTest\(' + 'prior-rollback broker-lock cut regression' = + 'RunInstallJournalBrokerLockRetirementSelfTest\(' 'installed INF ownership' = 'DEVPKEY_Device_DriverInfPath' 'installed version ownership' = 'DEVPKEY_Device_DriverVersion' 'documented add-only package staging' = 'SetupCopyOEMInfW\(' @@ -268,6 +278,265 @@ foreach ($entry in $requiredContracts.GetEnumerator()) { } } +$publishedInfResolver = Get-SourceContractRegion -Text $source ` + -Start 'bool GetPublishedInfPath(' ` + -End 'bool GetDriverStoreInfPath(' ` + -Name 'published INF resolver' +if ([regex]::Matches( + $publishedInfResolver, 'SetupGetInfPublishedNameW\s*\(').Count -ne 1) { + throw 'Published INF resolution must use exactly one SetupGetInfPublishedNameW call.' +} +if ($publishedInfResolver -match + 'SetupGetInfPublishedNameW\([\s\S]{0,300}nullptr\s*,\s*0') { + throw 'Published INF resolution must not use a NULL/zero sizing probe.' +} +Assert-OrderedSourceFragments -Text $publishedInfResolver ` + -Name 'single-call published INF resolution' -Fragments @( + 'std::array buffer{};', + 'buffer.fill(static_cast(0xffffU));', + 'SetupGetInfPublishedNameW(', + 'infPath.c_str(), buffer.data()', + 'static_cast(buffer.size()), nullptr', + 'DecodeFixedPublishedInfReturnBuffer(', + 'GetCanonicalSystemInfDirectory(', + 'NormalizePublishedInfPath(', + 'ValidatePublishedInfPath(' + ) + +$stageCandidateSource = Get-SourceContractRegion -Text $source ` + -Start 'bool StageCandidatePackage(' -End 'bool RemoveDevice(' ` + -Name 'candidate package staging' +Assert-OrderedSourceFragments -Text $stageCandidateSource ` + -Name 'documented SetupAPI package round trip' -Fragments @( + 'SetupCopyOEMInfW(', + 'DecodePublishedInfReturnBuffer(', + 'NormalizePublishedInfPath(', + 'ValidatePublishedInfPath(', + 'GetDriverStoreInfPath(', + 'GetPublishedInfPath(', + 'FindPublishedCandidate(' + ) +if ($stageCandidateSource.Contains( + 'GetPublishedInfPath(candidate.infPath')) { + throw 'Candidate source INF must never be passed to SetupGetInfPublishedNameW.' +} +foreach ($fragment in @( + 'PWSTR destinationComponent = nullptr;', + '&required, &destinationComponent', + 'destinationComponent == destination.data() + componentOffset')) { + if (-not $stageCandidateSource.Contains($fragment)) { + throw "Candidate staging lost SetupCopyOEMInf's exact destination receipt '$fragment'." + } +} + +$packageDigestSource = Get-SourceContractRegion -Text $source ` + -Start 'bool SamePackageBytes(' -End 'std::string PackageBytesKey(' ` + -Name 'package digest comparison' +if ([regex]::Matches( + $packageDigestSource, 'SameCanonicalPackageDigest\(').Count -ne 3) { + throw 'Package identity must canonically compare exactly INF, SYS, and CAT digests.' +} +$canonicalDigestComparison = Get-SourceContractRegion -Text $source ` + -Start 'bool SameCanonicalPackageDigest(' ` + -End 'bool SamePackageBytes(' ` + -Name 'allocation-free package digest comparison' +foreach ($fragment in @( + 'std::string_view right) noexcept', + 'left.size() != 64U || right.size() != 64U', + 'canonicalCharacter', 'canonicalLeft != canonicalRight')) { + if (-not $canonicalDigestComparison.Contains($fragment)) { + throw "Allocation-free package digest comparison lost '$fragment'." + } +} +if ($canonicalDigestComparison.Contains('CanonicalizePackageDigest(') -or + $canonicalDigestComparison -match 'std::string\s+canonical') { + throw 'Noexcept package digest comparison must remain allocation-free.' +} +$packageDigestKeySource = Get-SourceContractRegion -Text $source ` + -Start 'std::string PackageBytesKey(' ` + -End 'bool GetDriverStoreInfPath(' ` + -Name 'canonical package digest key' +if ([regex]::Matches( + $packageDigestKeySource, 'CanonicalizePackageDigest\(').Count -ne 3 -or + -not $packageDigestKeySource.Contains( + 'return inf + ":" + sys + ":" + cat;')) { + throw 'Package byte keys must canonicalize exactly INF, SYS, and CAT digests.' +} +if ($source -match + '(?:infSha256|sysSha256|catSha256)\s*(?:==|!=)|(?:==|!=)\s*[^;\r\n]*(?:infSha256|sysSha256|catSha256)') { + throw 'Package digest identity must never use case-sensitive direct comparison.' +} +foreach ($fragment in @( + 'value.size() != 64U', + "character >= 'A' && character <= 'F'", + "character + ('a' - 'A')", + 'PackageInfo mixedCaseCandidate = candidate;', + 'PackageInfo mismatchedCandidate = mixedCaseCandidate;', + 'PackageInfo malformedCandidate = candidate;', + 'self-test-package-digest-canonicalization')) { + if (-not $source.Contains($fragment)) { + throw "Canonical package digest validation lost '$fragment'." + } +} + +$normalizerSource = Get-SourceContractRegion -Text $source ` + -Start 'bool NormalizePublishedInfPath(' ` + -End 'bool DecodePublishedInfReturnBuffer(' ` + -Name 'pure published INF normalizer' +Assert-OrderedSourceFragments -Text $normalizerSource ` + -Name 'pure published INF normalization' -Fragments @( + "value.find(L'\0')", + 'IsDriveAbsoluteCanonicalPath(systemRoot)', + 'IsDriveAbsoluteCanonicalPath(value)', + 'component == L"." || component == L".."', + 'OrdinalPathEqualsInsensitive(parent, systemRoot)', + 'IsSafePublishedInfName(fileName)' + ) +foreach ($forbidden in @( + 'GetFileAttributesW(', 'CreateFileW(', 'GetFinalPathNameByHandleW(', + 'std::filesystem::canonical(')) { + if ($normalizerSource.Contains($forbidden)) { + throw "Pure published INF normalization gained filesystem access '$forbidden'." + } +} + +$publishedInfBasicAttributes = Get-SourceContractRegion -Text $source ` + -Start 'constexpr DWORD kPublishedInfBasicUnsafeAttributes' ` + -End 'constexpr DWORD kPublishedInfEnumerationRecallOnOpen' ` + -Name 'published INF basic attributes' +if ($publishedInfBasicAttributes -match '0x00040000UL\s*\|' -or + -not $source.Contains( + 'constexpr DWORD kPublishedInfEnumerationRecallOnOpen = 0x00040000UL;')) { + throw 'RECALL_ON_OPEN must be rejected only from directory-enumeration attributes, never handle/basic EA attributes.' +} + +$publishedInfValidator = Get-SourceContractRegion -Text $source ` + -Start 'bool ValidatePublishedInfPath(' ` + -End 'bool GetPublishedInfPath(' ` + -Name 'published INF local-file validator' +Assert-OrderedSourceFragments -Text $publishedInfValidator ` + -Name 'published INF local-file validation' -Fragments @( + 'path.parent_path().native(), canonicalSystemInf.native()', + 'IsSafePublishedInfName(path.filename().wstring())', + 'FindFirstFileW(path.c_str(), &enumeration)', + 'PublishedInfEnumerationAttributesAreSafe(', + 'GetFileAttributesW(path.c_str())', + 'PublishedInfBasicAttributesAreSafe(rawAttributes, false)', + 'path.c_str(), FILE_READ_ATTRIBUTES', + 'FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE', + 'OPEN_EXISTING', + 'FILE_FLAG_OPEN_REPARSE_POINT', + 'FILE_FLAG_OPEN_NO_RECALL', + 'FileAttributeTagInfo', + 'PublishedInfBasicAttributesAreSafe(', + 'GetFileType(file.get()) != FILE_TYPE_DISK', + 'GetFinalPathNameByHandleW(', + 'OrdinalPathEqualsInsensitive(finalPath, path.native())' + ) +foreach ($forbidden in @( + 'CREATE_NEW', 'CREATE_ALWAYS', 'OPEN_ALWAYS', 'TRUNCATE_EXISTING', + 'GENERIC_WRITE', 'FILE_WRITE_DATA', 'DeleteFileW(', 'MoveFileExW(')) { + if ($publishedInfValidator.Contains($forbidden)) { + throw "Published INF validation gained mutation capability '$forbidden'." + } +} + +$driverInfoSource = Get-SourceContractRegion -Text $source ` + -Start 'bool DriverInfoUsesPublishedPackage(' ` + -End 'struct PreparedDriverBinding {' ` + -Name 'compatible-driver package identity' +Assert-OrderedSourceFragments -Text $driverInfoSource ` + -Name 'compatible-driver system-INF containment' -Fragments @( + 'IsSafePublishedInfName(expectedPublishedName)', + 'GetPublishedInfPath(driverInfPath, &publishedPath, &ignored)', + 'OrdinalPathEqualsInsensitive(' + ) +if ($driverInfoSource.Contains( + 'IsSafePublishedInfName(driverInfPath.filename().wstring())')) { + throw 'A compatible-driver basename must not bypass system-INF containment.' +} + +$priorRetirementSource = Get-SourceContractRegion -Text $source ` + -Start 'bool InstallJournal::RetireAfterPriorValidation(' ` + -End 'bool RequireJournalObject(' ` + -Name 'prior rollback retirement' +Assert-OrderedSourceFragments -Text $priorRetirementSource ` + -Name 'prior rollback lock release before retirement' -Fragments @( + 'impl_->candidateLocks.clear();', + 'impl_->brokerLock.reset();', + 'impl_->priorBackups.clear();', + 'RetireInstallRecoveryActiveDirectory(' + ) + +$brokerLockCutSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RunInstallJournalBrokerLockRetirementSelfTest(' ` + -End 'bool RunInstallJournalModelSelfTest(' ` + -Name 'prior rollback broker-lock cut test' +Assert-OrderedSourceFragments -Text $brokerLockCutSource ` + -Name 'prior rollback broker-lock cut test' -Fragments @( + 'std::filesystem::temp_directory_path(', + 'FILE_SHARE_READ,', + 'MoveFileExW(active.c_str(), settled.c_str(), MOVEFILE_WRITE_THROUGH)', + 'brokerLock.reset();', + 'MoveFileExW(active.c_str(), settled.c_str(), MOVEFILE_WRITE_THROUGH)' + ) + +$normalizationTestSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RunPublishedInfNormalizationSelfTest(' ` + -End 'bool RunPublishedInfRoundTripLiveSelfTest(' ` + -Name 'published INF normalization self-test' +foreach ($fragment in @( + 'L"oem0.inf"', 'L"OEM1.INF"', 'L"oem9.inf"', + 'LR"(\\server\share\oem1.inf)"', + 'LR"(\\?\C:\Windows\INF\oem1.inf)"', + 'L"oem1.inf:stream"', 'embeddedNul.push_back', + 'unterminated.fill', 'FILE_ATTRIBUTE_REPARSE_POINT', + 'FILE_ATTRIBUTE_OFFLINE', + 'kPublishedInfEnumerationRecallOnOpen', + 'PublishedInfEnumerationAttributesAreSafe(')) { + if (-not $normalizationTestSource.Contains($fragment)) { + throw "Published INF normalization self-test lost '$fragment'." + } +} + +$liveRoundTripSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RunPublishedInfRoundTripLiveSelfTest(' ` + -End 'bool MultiSzContains(' ` + -Name 'live published INF round trip' +Assert-OrderedSourceFragments -Text $liveRoundTripSource ` + -Name 'read-only live published INF round trip' -Fragments @( + 'QueryProcessElevation(&elevated, error)', + 'if (!elevated) return true;', + 'EnumerateOwnedPackages(&packages, error)', + 'if (packages.empty()) return true;', + 'GetDriverStoreInfPath(', + 'GetPublishedInfPath(', + 'std::filesystem::path(package.publishedName)', + 'FindPublishedCandidate(', + 'SamePackageBytes(unique, package)' + ) +foreach ($forbidden in @( + 'SetupCopyOEMInfW(', 'SetupUninstallOEMInfW(', + 'DiInstallDevice(', 'DiUninstallDriverW(', 'DiUninstallDevice(', + 'RemoveDevice(', 'MoveFileExW(', 'DeleteFileW(')) { + if ($liveRoundTripSource.Contains($forbidden)) { + throw "Live SetupAPI round-trip self-test gained forbidden mutation '$forbidden'." + } +} + +$ownedPackageEnumeration = Get-SourceContractRegion -Text $source ` + -Start 'bool EnumerateOwnedPackages(' ` + -End 'bool FindPublishedCandidate(' ` + -Name 'safe owned-package enumeration' +Assert-OrderedSourceFragments -Text $ownedPackageEnumeration ` + -Name 'safe owned-package enumeration' -Fragments @( + 'GetCanonicalSystemInfDirectory(&infDirectory, error)', + 'FindFirstFileW(pattern.c_str(), &data)', + 'PublishedInfEnumerationAttributesAreSafe(', + 'ValidatePublishedInfPath(', + 'LoadOwnedPackage(publishedInf, false, false' + ) + $recordlessRecovery = Get-SourceContractRegion -Text $source ` -Start 'Outcome RecoverFailedInstallRecordless(' ` -End 'enum class InstallJournalRecoveryModelAction' ` diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index f00dea76..ea46d88d 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -736,17 +736,36 @@ class OuterPackageMutexWitness final { WinHandle mutex_; }; -bool IsElevated() { +bool QueryProcessElevation(bool* elevated, Error* error) { + if (elevated == nullptr) { + return SetError(error, L"process-elevation", ERROR_INVALID_PARAMETER); + } + *elevated = false; WinHandle token; HANDLE raw = nullptr; if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw)) { - return false; + return SetLastErrorDetail(error, L"process-elevation-token"); } token.reset(raw); TOKEN_ELEVATION elevation{}; DWORD returned = 0; - return GetTokenInformation(token.get(), TokenElevation, &elevation, sizeof(elevation), &returned) && - elevation.TokenIsElevated != 0; + if (!GetTokenInformation( + token.get(), TokenElevation, &elevation, + sizeof(elevation), &returned)) { + return SetLastErrorDetail(error, L"process-elevation-query"); + } + if (returned != sizeof(elevation)) { + return SetError(error, L"process-elevation-query", ERROR_INVALID_DATA, + L"Windows returned an unexpected TOKEN_ELEVATION record size"); + } + *elevated = elevation.TokenIsElevated != 0; + return true; +} + +bool IsElevated() { + bool elevated = false; + Error ignored; + return QueryProcessElevation(&elevated, &ignored) && elevated; } struct Version { @@ -1529,14 +1548,74 @@ struct PackageInfo { std::string catSha256; }; -bool SamePackageBytes(const PackageInfo& left, const PackageInfo& right) { - return left.infSha256 == right.infSha256 && - left.sysSha256 == right.sysSha256 && - left.catSha256 == right.catSha256; +bool CanonicalizePackageDigest( + std::string_view value, + std::string* canonical) { + if (canonical == nullptr) return false; + canonical->clear(); + if (value.size() != 64U) return false; + canonical->reserve(value.size()); + for (const unsigned char character : value) { + if (character >= '0' && character <= '9') { + canonical->push_back(static_cast(character)); + } else if (character >= 'a' && character <= 'f') { + canonical->push_back(static_cast(character)); + } else if (character >= 'A' && character <= 'F') { + canonical->push_back( + static_cast(character + ('a' - 'A'))); + } else { + canonical->clear(); + return false; + } + } + return true; +} + +bool SameCanonicalPackageDigest( + std::string_view left, + std::string_view right) noexcept { + if (left.size() != 64U || right.size() != 64U) return false; + const auto canonicalCharacter = [] (unsigned char character) noexcept { + if ((character >= '0' && character <= '9') || + (character >= 'a' && character <= 'f')) { + return static_cast(character); + } + if (character >= 'A' && character <= 'F') { + return static_cast(character + ('a' - 'A')); + } + return -1; + }; + for (size_t index = 0; index < left.size(); ++index) { + const int canonicalLeft = canonicalCharacter( + static_cast(left[index])); + const int canonicalRight = canonicalCharacter( + static_cast(right[index])); + if (canonicalLeft < 0 || canonicalRight < 0 || + canonicalLeft != canonicalRight) { + return false; + } + } + return true; +} + +bool SamePackageBytes( + const PackageInfo& left, + const PackageInfo& right) noexcept { + return SameCanonicalPackageDigest(left.infSha256, right.infSha256) && + SameCanonicalPackageDigest(left.sysSha256, right.sysSha256) && + SameCanonicalPackageDigest(left.catSha256, right.catSha256); } std::string PackageBytesKey(const PackageInfo& package) { - return package.infSha256 + ":" + package.sysSha256 + ":" + package.catSha256; + std::string inf; + std::string sys; + std::string cat; + if (!CanonicalizePackageDigest(package.infSha256, &inf) || + !CanonicalizePackageDigest(package.sysSha256, &sys) || + !CanonicalizePackageDigest(package.catSha256, &cat)) { + return {}; + } + return inf + ":" + sys + ":" + cat; } bool GetDriverStoreInfPath( @@ -2008,66 +2087,336 @@ bool LoadOwnedPackage( bool IsSafePublishedInfName(const std::wstring& value) { const std::filesystem::path path(value); - if (path.has_parent_path() || path.filename().wstring() != value || value.size() < 9) { + if (path.has_parent_path() || path.filename().wstring() != value || + value.size() < 8U) { return false; } - std::wstring lower = value; - std::transform(lower.begin(), lower.end(), lower.begin(), [](wchar_t character) { - return static_cast(towlower(character)); - }); - if (!lower.starts_with(L"oem") || !lower.ends_with(L".inf")) { + const auto asciiLower = [](wchar_t character) { + return character >= L'A' && character <= L'Z' + ? static_cast(character + (L'a' - L'A')) + : character; + }; + if (asciiLower(value[0]) != L'o' || asciiLower(value[1]) != L'e' || + asciiLower(value[2]) != L'm' || + asciiLower(value[value.size() - 4U]) != L'.' || + asciiLower(value[value.size() - 3U]) != L'i' || + asciiLower(value[value.size() - 2U]) != L'n' || + asciiLower(value[value.size() - 1U]) != L'f') { return false; } - return std::all_of(lower.begin() + 3, lower.end() - 4, [](wchar_t character) { + return std::all_of(value.begin() + 3, value.end() - 4, [](wchar_t character) { return character >= L'0' && character <= L'9'; }); } bool GetSystemInfDirectory(std::filesystem::path* directory, Error* error) { std::vector buffer(MAX_PATH); - const UINT length = GetWindowsDirectoryW(buffer.data(), static_cast(buffer.size())); + const UINT length = GetSystemWindowsDirectoryW( + buffer.data(), static_cast(buffer.size())); if (length == 0) { - return SetLastErrorDetail(error, L"windows-directory"); + return SetLastErrorDetail(error, L"system-windows-directory"); } if (static_cast(length) >= buffer.size()) { buffer.resize(static_cast(length) + 1); - const UINT retry = GetWindowsDirectoryW(buffer.data(), static_cast(buffer.size())); + const UINT retry = GetSystemWindowsDirectoryW( + buffer.data(), static_cast(buffer.size())); if (retry == 0 || static_cast(retry) >= buffer.size()) { - return SetLastErrorDetail(error, L"windows-directory"); + return SetLastErrorDetail(error, L"system-windows-directory"); } } *directory = std::filesystem::path(buffer.data()) / L"INF"; return true; } +constexpr DWORD kPublishedInfBasicUnsafeAttributes = + FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_OFFLINE | + 0x00080000UL | // FILE_ATTRIBUTE_PINNED + 0x00100000UL | // FILE_ATTRIBUTE_UNPINNED + 0x00400000UL; // FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS +// 0x00040000 is FILE_ATTRIBUTE_RECALL_ON_OPEN only in directory-enumeration +// records; in basic/handle attribute records the same bit means +// FILE_ATTRIBUTE_EA and must not be misclassified as cloud state. +constexpr DWORD kPublishedInfEnumerationRecallOnOpen = 0x00040000UL; + +bool PublishedInfBasicAttributesAreSafe( + DWORD attributes, + bool expectDirectory) noexcept { + if (attributes == INVALID_FILE_ATTRIBUTES || + (attributes & kPublishedInfBasicUnsafeAttributes) != 0) { + return false; + } + const bool directory = (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0; + return directory == expectDirectory; +} + +bool PublishedInfEnumerationAttributesAreSafe( + DWORD attributes, + bool expectDirectory) noexcept { + return PublishedInfBasicAttributesAreSafe( + attributes, expectDirectory) && + (attributes & kPublishedInfEnumerationRecallOnOpen) == 0; +} + +bool OrdinalPathEqualsInsensitive( + std::wstring_view left, + std::wstring_view right) noexcept { + if (left.size() > static_cast(std::numeric_limits::max()) || + right.size() > static_cast(std::numeric_limits::max())) { + return false; + } + return CompareStringOrdinal( + left.data(), static_cast(left.size()), + right.data(), static_cast(right.size()), TRUE) == CSTR_EQUAL; +} + +bool IsDriveAbsoluteCanonicalPath(std::wstring_view value) noexcept { + return value.size() >= 4U && + ((value[0] >= L'A' && value[0] <= L'Z') || + (value[0] >= L'a' && value[0] <= L'z')) && + value[1] == L':' && value[2] == L'\\' && + value.find(L'/', 0) == std::wstring_view::npos && + value.find(L':', 2) == std::wstring_view::npos; +} + +bool NormalizePublishedInfPath( + std::wstring_view value, + const std::filesystem::path& canonicalSystemInf, + std::filesystem::path* normalized) { + if (normalized == nullptr || value.empty() || value.size() >= MAX_PATH || + value.find(L'\0') != std::wstring_view::npos) { + return false; + } + std::wstring systemRoot = canonicalSystemInf.native(); + while (systemRoot.size() > 3U && systemRoot.back() == L'\\') { + systemRoot.pop_back(); + } + if (!IsDriveAbsoluteCanonicalPath(systemRoot)) { + return false; + } + + std::wstring fileName; + if (value.find_first_of(L"\\/:") == std::wstring_view::npos) { + fileName.assign(value); + } else { + if (!IsDriveAbsoluteCanonicalPath(value) || + value.starts_with(L"\\\\") || value.starts_with(L"\\\\?\\") || + value.starts_with(L"\\\\.\\")) { + return false; + } + const size_t lastSeparator = value.rfind(L'\\'); + if (lastSeparator <= 2U || lastSeparator + 1U >= value.size()) { + return false; + } + size_t componentStart = 3U; + while (componentStart < lastSeparator) { + const size_t separator = value.find(L'\\', componentStart); + const size_t componentEnd = separator == std::wstring_view::npos || + separator > lastSeparator + ? lastSeparator : separator; + const std::wstring_view component = + value.substr(componentStart, componentEnd - componentStart); + if (component.empty() || component == L"." || component == L"..") { + return false; + } + if (componentEnd == lastSeparator) { + break; + } + componentStart = componentEnd + 1U; + } + const std::wstring_view parent = value.substr(0, lastSeparator); + if (!OrdinalPathEqualsInsensitive(parent, systemRoot)) { + return false; + } + fileName.assign(value.substr(lastSeparator + 1U)); + } + if (!IsSafePublishedInfName(fileName)) { + return false; + } + const std::filesystem::path result = + std::filesystem::path(systemRoot) / fileName; + if (result.native().size() >= MAX_PATH) { + return false; + } + *normalized = result; + return true; +} + +bool DecodePublishedInfReturnBuffer( + const wchar_t* buffer, + size_t capacity, + DWORD required, + std::wstring_view* value) noexcept { + if (buffer == nullptr || value == nullptr || capacity == 0U || + capacity > MAXDWORD || required == 0U || required > capacity) { + return false; + } + const size_t length = wcsnlen_s(buffer, capacity); + if (length == 0U || length >= capacity || + required != static_cast(length + 1U)) { + return false; + } + *value = std::wstring_view(buffer, length); + return true; +} + +bool DecodeFixedPublishedInfReturnBuffer( + const wchar_t* buffer, + size_t capacity, + std::wstring_view* value) noexcept { + if (buffer == nullptr || value == nullptr || capacity == 0U) { + return false; + } + const size_t length = wcsnlen_s(buffer, capacity); + if (length == 0U || length >= capacity) { + return false; + } + *value = std::wstring_view(buffer, length); + return true; +} + +bool GetCanonicalSystemInfDirectory( + std::filesystem::path* canonicalSystemInf, + Error* error) { + std::filesystem::path systemInf; + if (!GetSystemInfDirectory(&systemInf, error)) { + return false; + } + const DWORD rawAttributes = GetFileAttributesW(systemInf.c_str()); + if (!PublishedInfBasicAttributesAreSafe(rawAttributes, true)) { + return SetError(error, L"system-inf-directory", ERROR_REPARSE_TAG_MISMATCH, + L"the system INF directory must be a local non-reparse directory"); + } + std::error_code canonicalError; + const std::filesystem::path canonical = + std::filesystem::canonical(systemInf, canonicalError); + if (canonicalError || canonical.empty() || + canonical.native().size() >= MAX_PATH || + !PublishedInfBasicAttributesAreSafe( + GetFileAttributesW(canonical.c_str()), true)) { + return SetError(error, L"system-inf-directory", + canonicalError ? static_cast(canonicalError.value()) + : ERROR_INVALID_NAME, + L"the canonical system INF directory is unavailable or unsafe"); + } + *canonicalSystemInf = canonical; + return true; +} + +bool ValidatePublishedInfPath( + const std::filesystem::path& path, + const std::filesystem::path& canonicalSystemInf, + Error* error) { + if (!OrdinalPathEqualsInsensitive( + path.parent_path().native(), canonicalSystemInf.native()) || + !IsSafePublishedInfName(path.filename().wstring())) { + return SetError(error, L"published-inf", ERROR_INVALID_NAME, + L"published INF is outside the canonical system INF directory"); + } + WIN32_FIND_DATAW enumeration{}; + HANDLE rawFind = FindFirstFileW(path.c_str(), &enumeration); + if (rawFind == INVALID_HANDLE_VALUE) { + return SetLastErrorDetail(error, L"published-inf-enumeration"); + } + const BOOL findClosed = FindClose(rawFind); + if (!findClosed) { + return SetLastErrorDetail(error, L"published-inf-enumeration-close"); + } + if (!OrdinalPathEqualsInsensitive( + enumeration.cFileName, path.filename().wstring()) || + !PublishedInfEnumerationAttributesAreSafe( + enumeration.dwFileAttributes, false)) { + return SetError(error, L"published-inf-enumeration", + ERROR_REPARSE_TAG_MISMATCH, + L"published INF directory receipt identifies reparse, offline, recall, or cloud state"); + } + const DWORD rawAttributes = GetFileAttributesW(path.c_str()); + if (rawAttributes == INVALID_FILE_ATTRIBUTES) { + return SetLastErrorDetail(error, L"published-inf-attributes"); + } + if (!PublishedInfBasicAttributesAreSafe(rawAttributes, false)) { + return SetError(error, L"published-inf-attributes", + ERROR_REPARSE_TAG_MISMATCH, + L"published INF must be a present local non-reparse regular file"); + } + WinHandle file(CreateFileW( + path.c_str(), FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_OPEN_NO_RECALL, + nullptr)); + if (!file) { + return SetLastErrorDetail(error, L"published-inf-open"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx( + file.get(), FileAttributeTagInfo, &attributes, + sizeof(attributes)) || + !PublishedInfBasicAttributesAreSafe( + attributes.FileAttributes, false) || + GetFileType(file.get()) != FILE_TYPE_DISK) { + return SetError(error, L"published-inf-open", ERROR_REPARSE_TAG_MISMATCH, + L"published INF must be a present local non-reparse regular file"); + } + std::array finalBuffer{}; + const DWORD finalLength = GetFinalPathNameByHandleW( + file.get(), finalBuffer.data(), static_cast(finalBuffer.size()), + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); + if (finalLength == 0U) { + return SetLastErrorDetail(error, L"published-inf-final-path"); + } + if (finalLength >= finalBuffer.size()) { + return SetError(error, L"published-inf-final-path", + ERROR_FILENAME_EXCED_RANGE, + L"published INF final path exceeds the supported SetupAPI MAX_PATH contract"); + } + std::wstring finalPath(finalBuffer.data(), finalLength); + if (finalPath.starts_with(L"\\\\?\\UNC\\")) { + return SetError(error, L"published-inf-final-path", ERROR_INVALID_NAME, + L"published INF resolved to a UNC path"); + } + if (finalPath.starts_with(L"\\\\?\\")) { + finalPath.erase(0U, 4U); + } + if (!OrdinalPathEqualsInsensitive(finalPath, path.native())) { + return SetError(error, L"published-inf-final-path", + ERROR_REPARSE_TAG_MISMATCH, + L"published INF final path differs from the canonical system INF receipt"); + } + return true; +} + bool GetPublishedInfPath( const std::filesystem::path& infPath, std::filesystem::path* publishedPath, Error* error) { - DWORD required = 0; - SetupGetInfPublishedNameW(infPath.c_str(), nullptr, 0, &required); - if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + std::array buffer{}; + buffer.fill(static_cast(0xffffU)); + if (!SetupGetInfPublishedNameW( + infPath.c_str(), buffer.data(), + static_cast(buffer.size()), nullptr)) { return SetLastErrorDetail(error, L"published-inf"); } - std::vector buffer(required); - if (!SetupGetInfPublishedNameW(infPath.c_str(), buffer.data(), required, nullptr)) { - return SetLastErrorDetail(error, L"published-inf"); + std::wstring_view returned; + if (!DecodeFixedPublishedInfReturnBuffer( + buffer.data(), buffer.size(), &returned)) { + return SetError(error, L"published-inf", ERROR_INVALID_DATA, + L"SetupAPI returned an empty or unterminated published INF path"); } - const std::filesystem::path result(buffer.data()); - std::filesystem::path systemInf; - if (!GetSystemInfDirectory(&systemInf, error)) { + std::filesystem::path canonicalSystemInf; + if (!GetCanonicalSystemInfDirectory(&canonicalSystemInf, error)) { return false; } - std::error_code parentError; - std::error_code systemError; - const std::filesystem::path canonicalParent = std::filesystem::canonical(result.parent_path(), parentError); - const std::filesystem::path canonicalSystemInf = std::filesystem::canonical(systemInf, systemError); - if (parentError || systemError || !IsSafePublishedInfName(result.filename().wstring()) || - _wcsicmp(canonicalParent.c_str(), canonicalSystemInf.c_str()) != 0) { + std::filesystem::path normalized; + if (!NormalizePublishedInfPath( + returned, canonicalSystemInf, &normalized)) { return SetError(error, L"published-inf", ERROR_INVALID_NAME, - L"SetupAPI returned a published INF outside the system INF directory"); + L"SetupAPI returned a noncanonical published INF path"); + } + if (!ValidatePublishedInfPath(normalized, canonicalSystemInf, error)) { + return false; } - *publishedPath = result; + *publishedPath = std::move(normalized); return true; } @@ -2076,23 +2425,36 @@ bool GetDriverStoreInfPath( std::filesystem::path* storePath, Error* error) { DWORD required = 0; - SetupGetInfDriverStoreLocationW(publishedPath.c_str(), nullptr, nullptr, nullptr, 0, &required); - if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + SetLastError(ERROR_SUCCESS); + const BOOL sized = SetupGetInfDriverStoreLocationW( + publishedPath.c_str(), nullptr, nullptr, nullptr, 0, &required); + const DWORD sizeError = GetLastError(); + if (sized || sizeError != ERROR_INSUFFICIENT_BUFFER || + required == 0U || required > MAX_PATH) { + SetLastError(sizeError == ERROR_SUCCESS ? ERROR_INVALID_DATA : sizeError); return SetLastErrorDetail(error, L"driver-store-inf"); } - std::vector buffer(required); + std::vector buffer(required, static_cast(0xffffU)); + DWORD observedRequired = 0; if (!SetupGetInfDriverStoreLocationW( - publishedPath.c_str(), nullptr, nullptr, buffer.data(), required, nullptr)) { + publishedPath.c_str(), nullptr, nullptr, buffer.data(), required, + &observedRequired)) { return SetLastErrorDetail(error, L"driver-store-inf"); } - *storePath = buffer.data(); + std::wstring_view returned; + if (!DecodePublishedInfReturnBuffer( + buffer.data(), buffer.size(), observedRequired, &returned)) { + return SetError(error, L"driver-store-inf", ERROR_INVALID_DATA, + L"SetupAPI returned an empty, unterminated, or length-inconsistent Driver Store INF path"); + } + *storePath = std::filesystem::path(returned); return true; } bool EnumerateOwnedPackages(std::vector* packages, Error* error) { packages->clear(); std::filesystem::path infDirectory; - if (!GetSystemInfDirectory(&infDirectory, error)) { + if (!GetCanonicalSystemInfDirectory(&infDirectory, error)) { return false; } const std::wstring pattern = (infDirectory / L"oem*.inf").wstring(); @@ -2105,17 +2467,28 @@ bool EnumerateOwnedPackages(std::vector* packages, Error* error) { return SetLastErrorDetail(error, L"enumerate-published-inf"); } do { - if ((data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 || - !IsSafePublishedInfName(data.cFileName)) { + if (!IsSafePublishedInfName(data.cFileName)) { continue; } + if (!PublishedInfEnumerationAttributesAreSafe( + data.dwFileAttributes, false)) { + FindClose(rawFind); + return SetError(error, L"enumerate-published-inf", + ERROR_REPARSE_TAG_MISMATCH, + L"an OEM INF directory receipt identifies reparse, offline, recall, or cloud state"); + } + const std::filesystem::path publishedInf = + infDirectory / data.cFileName; + if (!ValidatePublishedInfPath( + publishedInf, infDirectory, error)) { + FindClose(rawFind); + return false; + } PackageInfo package; bool owned = false; - Error packageError; - if (!LoadOwnedPackage(infDirectory / data.cFileName, false, false, - &package, &owned, &packageError)) { + if (!LoadOwnedPackage(publishedInf, false, false, + &package, &owned, error)) { FindClose(rawFind); - *error = std::move(packageError); return false; } if (owned) { @@ -2157,6 +2530,167 @@ bool FindPublishedCandidate( return true; } +bool RunPublishedInfNormalizationSelfTest(Error* error) { + const std::filesystem::path canonicalSystemInf = + LR"(C:\Windows\INF)"; + struct PositiveCase { + std::wstring value; + std::wstring fileName; + }; + for (const PositiveCase& test : { + PositiveCase{L"oem0.inf", L"oem0.inf"}, + PositiveCase{L"OEM1.INF", L"OEM1.INF"}, + PositiveCase{L"oem9.inf", L"oem9.inf"}, + PositiveCase{L"oem123456.inf", L"oem123456.inf"}, + PositiveCase{LR"(C:\Windows\INF\oem1.inf)", L"oem1.inf"}, + PositiveCase{LR"(c:\windows\inf\OEM9.INF)", L"OEM9.INF"}}) { + std::filesystem::path normalized; + if (!NormalizePublishedInfPath( + test.value, canonicalSystemInf, &normalized) || + !OrdinalPathEqualsInsensitive( + normalized.native(), + (canonicalSystemInf / test.fileName).native())) { + return SetError(error, L"self-test-published-inf-normalization", + ERROR_INVALID_DATA, + L"a valid bare or absolute system-INF published name was rejected"); + } + } + for (const std::wstring& value : std::vector{ + std::wstring{}, L"oem.inf", L"oem-1.inf", L"oem1.in", + L"oem1.inf.bak", LR"(.\oem1.inf)", LR"(..\oem1.inf)", + LR"(sub\oem1.inf)", L"C:oem1.inf", + LR"(\Windows\INF\oem1.inf)", + LR"(\\server\share\oem1.inf)", + LR"(\\?\C:\Windows\INF\oem1.inf)", + LR"(\\.\C:\Windows\INF\oem1.inf)", + LR"(C:\Windows\INF\..\Temp\oem1.inf)", + LR"(C:\Windows\INF\.\oem1.inf)", + LR"(C:\Temp\oem1.inf)", L"oem1.inf:stream", + LR"(C:\Windows\INF\oem1.inf:stream)", + L"C:/Windows/INF/oem1.inf"}) { + std::filesystem::path normalized; + if (NormalizePublishedInfPath( + value, canonicalSystemInf, &normalized)) { + return SetError(error, L"self-test-published-inf-normalization", + ERROR_INVALID_DATA, + L"an ambiguous, escaping, device, UNC, ADS, or malformed published path was admitted"); + } + } + std::wstring embeddedNul = L"oem1.inf"; + embeddedNul.push_back(L'\0'); + embeddedNul.append(L".bak"); + std::filesystem::path normalized; + if (NormalizePublishedInfPath( + embeddedNul, canonicalSystemInf, &normalized)) { + return SetError(error, L"self-test-published-inf-normalization", + ERROR_INVALID_DATA, + L"an embedded-NUL published name was admitted"); + } + + std::array receipt{}; + receipt.fill(static_cast(0xffffU)); + constexpr std::wstring_view validReceipt = L"oem1.inf"; + std::copy(validReceipt.begin(), validReceipt.end(), receipt.begin()); + receipt[validReceipt.size()] = L'\0'; + std::wstring_view decoded; + std::array unterminated{}; + unterminated.fill(L'x'); + if (!DecodeFixedPublishedInfReturnBuffer( + receipt.data(), receipt.size(), &decoded) || + decoded != validReceipt || + DecodeFixedPublishedInfReturnBuffer( + unterminated.data(), unterminated.size(), &decoded) || + !DecodePublishedInfReturnBuffer( + receipt.data(), receipt.size(), + static_cast(validReceipt.size() + 1U), &decoded) || + decoded != validReceipt || + DecodePublishedInfReturnBuffer( + receipt.data(), receipt.size(), + static_cast(validReceipt.size()), &decoded) || + DecodePublishedInfReturnBuffer( + receipt.data(), receipt.size(), 0U, &decoded) || + DecodePublishedInfReturnBuffer( + unterminated.data(), unterminated.size(), + static_cast(unterminated.size()), &decoded)) { + return SetError(error, L"self-test-published-inf-normalization", + ERROR_INVALID_DATA, + L"published INF receipt length or termination validation is not exact"); + } + if (!PublishedInfBasicAttributesAreSafe( + FILE_ATTRIBUTE_NORMAL, false) || + !PublishedInfBasicAttributesAreSafe( + FILE_ATTRIBUTE_DIRECTORY, true) || + PublishedInfBasicAttributesAreSafe( + FILE_ATTRIBUTE_DIRECTORY, false) || + PublishedInfBasicAttributesAreSafe( + FILE_ATTRIBUTE_NORMAL, true) || + !PublishedInfBasicAttributesAreSafe( + FILE_ATTRIBUTE_NORMAL | kPublishedInfEnumerationRecallOnOpen, + false) || + PublishedInfEnumerationAttributesAreSafe( + FILE_ATTRIBUTE_NORMAL | kPublishedInfEnumerationRecallOnOpen, + false)) { + return SetError(error, L"self-test-published-inf-normalization", + ERROR_INVALID_DATA, + L"published INF file/directory attribute classification is not exact"); + } + for (const DWORD unsafeAttribute : std::array{ + static_cast(FILE_ATTRIBUTE_REPARSE_POINT), + static_cast(FILE_ATTRIBUTE_OFFLINE), + 0x00080000UL, 0x00100000UL, 0x00400000UL}) { + if (PublishedInfBasicAttributesAreSafe( + FILE_ATTRIBUTE_NORMAL | unsafeAttribute, false) || + PublishedInfEnumerationAttributesAreSafe( + FILE_ATTRIBUTE_NORMAL | unsafeAttribute, false)) { + return SetError(error, L"self-test-published-inf-normalization", + ERROR_INVALID_DATA, + L"a reparse, offline, recall, pinned, or unpinned published INF was admitted"); + } + } + return true; +} + +bool RunPublishedInfRoundTripLiveSelfTest(Error* error) { + bool elevated = false; + if (!QueryProcessElevation(&elevated, error)) return false; + if (!elevated) return true; + std::vector packages; + if (!EnumerateOwnedPackages(&packages, error)) return false; + if (packages.empty()) return true; + for (const PackageInfo& package : packages) { + std::filesystem::path driverStoreInf; + std::filesystem::path fromDriverStore; + std::filesystem::path fromBareName; + PackageInfo unique; + if (!GetDriverStoreInfPath( + package.infPath, &driverStoreInf, error) || + !GetPublishedInfPath( + driverStoreInf, &fromDriverStore, error) || + !GetPublishedInfPath( + std::filesystem::path(package.publishedName), + &fromBareName, error) || + !FindPublishedCandidate(package, &unique, error)) { + return false; + } + if (!OrdinalPathEqualsInsensitive( + fromDriverStore.native(), package.infPath.native()) || + !OrdinalPathEqualsInsensitive( + fromBareName.native(), package.infPath.native()) || + !OrdinalPathEqualsInsensitive( + unique.infPath.native(), package.infPath.native()) || + !OrdinalPathEqualsInsensitive( + unique.publishedName, package.publishedName) || + unique.version != package.version || + !SamePackageBytes(unique, package)) { + return SetError(error, + L"self-test-published-inf-live-roundtrip", + ERROR_REVISION_MISMATCH, + L"an exact installed VIIPER package did not round-trip from system INF through Driver Store and back"); + } + } + return true; +} + bool MultiSzContains(const std::vector& value, const wchar_t* expected) { if (value.empty() || value.size() % sizeof(wchar_t) != 0) { return false; @@ -2670,21 +3204,16 @@ bool StageCandidatePackage( L"transaction-deadline-before-driver-stage", error)) { return false; } - std::filesystem::path systemInf; - if (!GetSystemInfDirectory(&systemInf, error)) { + std::filesystem::path canonicalSystemInf; + if (!GetCanonicalSystemInfDirectory(&canonicalSystemInf, error)) { + error->phase = L"stage-system-inf-directory"; return false; } - std::error_code systemInfError; - const std::filesystem::path canonicalSystemInf = - std::filesystem::canonical(systemInf, systemInfError); - if (systemInfError) { - return SetError(error, L"stage-system-inf-directory", - static_cast(systemInfError.value()), - L"the canonical system INF directory could not be captured before staging"); - } std::array destination{}; + destination.fill(static_cast(0xffffU)); DWORD required = 0; + PWSTR destinationComponent = nullptr; // SetupCopyOEMInf can publish bytes before returning or before subsequent // receipt validation. Mark the protected transaction as potentially // mutated before the API boundary; stagedHere remains success-only so @@ -2700,7 +3229,7 @@ bool StageCandidatePackage( return SetupCopyOEMInfW( sourcePath.c_str(), nullptr, SPOST_PATH, SP_COPY_NOOVERWRITE, destination.data(), static_cast(destination.size()), - &required, nullptr); + &required, &destinationComponent); }); const DWORD copyError = copied ? ERROR_SUCCESS : GetLastError(); Error journalReturnError; @@ -2729,20 +3258,30 @@ bool StageCandidatePackage( L"add-only candidate import into the Driver Store failed"); } + std::wstring_view destinationValue; const size_t destinationLength = wcsnlen_s(destination.data(), destination.size()); - bool receiptValid = destinationLength != 0 && + bool receiptValid = destinationLength != 0U && destinationLength < destination.size() && - required == destinationLength + 1; + DecodePublishedInfReturnBuffer( + destination.data(), destination.size(), required, + &destinationValue); std::filesystem::path destinationPath; if (receiptValid) { - destinationPath = destination.data(); - std::error_code parentError; - const std::filesystem::path canonicalParent = - std::filesystem::canonical(destinationPath.parent_path(), parentError); - receiptValid = !parentError && - IsSafePublishedInfName(destinationPath.filename().wstring()) && - _wcsicmp(canonicalParent.c_str(), canonicalSystemInf.c_str()) == 0; + receiptValid = NormalizePublishedInfPath( + destinationValue, canonicalSystemInf, &destinationPath) && + ValidatePublishedInfPath( + destinationPath, canonicalSystemInf, error); + } + if (receiptValid) { + const std::wstring fileName = destinationPath.filename().wstring(); + const size_t componentOffset = + destinationValue.size() - fileName.size(); + receiptValid = componentOffset < destinationValue.size() && + destinationComponent == destination.data() + componentOffset && + OrdinalPathEqualsInsensitive( + std::wstring_view(destinationComponent, fileName.size()), + fileName); } if (receiptValid) { // Preserve the API's exact, safe published-name receipt immediately. @@ -2761,14 +3300,22 @@ bool StageCandidatePackage( return SetError(error, L"stage-published-inf", ERROR_INVALID_DATA, L"SetupCopyOEMInf returned a malformed published INF identity"); } + std::filesystem::path driverStoreInfPath; std::filesystem::path resolvedPublishedPath; PackageInfo verifiedPublished; - if (!GetPublishedInfPath(candidate.infPath, &resolvedPublishedPath, error) || - _wcsicmp(resolvedPublishedPath.c_str(), destinationPath.c_str()) != 0 || + if (!GetDriverStoreInfPath( + destinationPath, &driverStoreInfPath, error) || + !GetPublishedInfPath( + driverStoreInfPath, &resolvedPublishedPath, error) || + !OrdinalPathEqualsInsensitive( + resolvedPublishedPath.native(), destinationPath.native()) || !FindPublishedCandidate(candidate, &verifiedPublished, error) || - _wcsicmp(verifiedPublished.infPath.c_str(), resolvedPublishedPath.c_str()) != 0 || - _wcsicmp(verifiedPublished.publishedName.c_str(), - resolvedPublishedPath.filename().c_str()) != 0) { + !OrdinalPathEqualsInsensitive( + verifiedPublished.infPath.native(), + resolvedPublishedPath.native()) || + !OrdinalPathEqualsInsensitive( + verifiedPublished.publishedName, + resolvedPublishedPath.filename().wstring())) { if (error->code == ERROR_SUCCESS) { SetError(error, L"stage-published-inf", ERROR_REVISION_MISMATCH, L"add-only staging did not resolve to the unique exact candidate package"); @@ -3012,14 +3559,12 @@ bool RegisterRootDevice( bool DriverInfoUsesPublishedPackage( const std::filesystem::path& driverInfPath, const std::wstring& expectedPublishedName) { - if (IsSafePublishedInfName(driverInfPath.filename().wstring())) { - return _wcsicmp( - driverInfPath.filename().c_str(), expectedPublishedName.c_str()) == 0; - } + if (!IsSafePublishedInfName(expectedPublishedName)) return false; std::filesystem::path publishedPath; Error ignored; return GetPublishedInfPath(driverInfPath, &publishedPath, &ignored) && - _wcsicmp(publishedPath.filename().c_str(), expectedPublishedName.c_str()) == 0; + OrdinalPathEqualsInsensitive( + publishedPath.filename().wstring(), expectedPublishedName); } struct PreparedDriverBinding { @@ -8331,6 +8876,7 @@ bool InstallJournal::RetireAfterPriorValidation( return false; } impl_->candidateLocks.clear(); + impl_->brokerLock.reset(); impl_->priorBackups.clear(); if (!RetireInstallRecoveryActiveDirectory( &impl_->directory, impl_->state.transactionId, @@ -15982,7 +16528,90 @@ InstallJournalRecoveryModelAction ClassifyInstallJournalRecoveryModel( return InstallJournalRecoveryModelAction::RollbackPrior; } +bool RunInstallJournalBrokerLockRetirementSelfTest(Error* error) { + std::string rootIdentity; + if (!GenerateInstallTransactionId(&rootIdentity, error)) return false; + std::error_code pathError; + const std::filesystem::path temporary = + std::filesystem::temp_directory_path(pathError); + if (pathError) { + return SetError(error, + L"self-test-install-journal-prior-retire-temp", + static_cast(pathError.value())); + } + const std::filesystem::path testRoot = temporary / + (L"VIIPER-UdeCx-install-prior-retire-" + + std::wstring(rootIdentity.begin(), rootIdentity.end())); + const std::filesystem::path active = + testRoot / kInstallRecoveryActiveDirectory; + const std::filesystem::path brokerDirectory = + active / kInstallRecoveryBrokerDirectory; + const std::filesystem::path brokerImage = + brokerDirectory / L"broker-cut.bin"; + const std::filesystem::path settled = testRoot / + (std::wstring(kInstallRecoverySettledPrefix) + + std::wstring(rootIdentity.begin(), rootIdentity.end())); + if (!std::filesystem::create_directories(brokerDirectory, pathError) || + pathError) { + return SetError(error, + L"self-test-install-journal-prior-retire-root", + pathError ? static_cast(pathError.value()) + : ERROR_ALREADY_EXISTS); + } + struct Cleanup final { + std::filesystem::path root; + ~Cleanup() { + std::error_code ignored; + std::filesystem::remove_all(root, ignored); + } + } cleanup{testRoot}; + + WinHandle brokerLock(CreateFileW( + brokerImage.c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr)); + if (!brokerLock) { + return SetLastErrorDetail(error, + L"self-test-install-journal-prior-retire-broker-lock"); + } + if (MoveFileExW(active.c_str(), settled.c_str(), MOVEFILE_WRITE_THROUGH)) { + return SetError(error, + L"self-test-install-journal-prior-retire-broker-lock-cut", + ERROR_INVALID_DATA, + L"install retirement unexpectedly bypassed a broker image handle without delete sharing"); + } + const DWORD blockedError = GetLastError(); + if (blockedError != ERROR_SHARING_VIOLATION && + blockedError != ERROR_ACCESS_DENIED && + blockedError != ERROR_LOCK_VIOLATION) { + return SetError(error, + L"self-test-install-journal-prior-retire-broker-lock-cut", + blockedError, + L"install retirement failed for an unexpected reason at the broker-lock cut"); + } + brokerLock.reset(); + if (!MoveFileExW(active.c_str(), settled.c_str(), MOVEFILE_WRITE_THROUGH)) { + return SetLastErrorDetail(error, + L"self-test-install-journal-prior-retire-broker-lock-release"); + } + const DWORD activeAttributes = GetFileAttributesW(active.c_str()); + const DWORD activeError = activeAttributes == INVALID_FILE_ATTRIBUTES + ? GetLastError() : ERROR_SUCCESS; + const DWORD settledAttributes = GetFileAttributesW(settled.c_str()); + if (activeAttributes != INVALID_FILE_ATTRIBUTES || + (activeError != ERROR_FILE_NOT_FOUND && + activeError != ERROR_PATH_NOT_FOUND) || + settledAttributes == INVALID_FILE_ATTRIBUTES || + (settledAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0) { + return SetError(error, + L"self-test-install-journal-prior-retire-broker-lock-release", + ERROR_INVALID_DATA, + L"releasing the broker image lock did not admit exactly one write-through terminal rename"); + } + return true; +} + bool RunInstallJournalModelSelfTest(Error* error) { + if (!RunInstallJournalBrokerLockRetirementSelfTest(error)) return false; const std::wstring modelTargetUserSid = L"S-1-5-21-1-2-3-1001"; std::wstring modelProductSecurity; @@ -17755,7 +18384,9 @@ Outcome Status() { Outcome SelfTest() { Outcome outcome; if (!RunInstallJournalModelSelfTest(&outcome.error) || - !RunRemoveJournalModelSelfTest(&outcome.error)) { + !RunRemoveJournalModelSelfTest(&outcome.error) || + !RunPublishedInfNormalizationSelfTest(&outcome.error) || + !RunPublishedInfRoundTripLiveSelfTest(&outcome.error)) { return outcome; } const std::string canonicalSha256 = @@ -17800,9 +18431,38 @@ Outcome SelfTest() { } PackageInfo candidate; candidate.version = two; - candidate.infSha256 = "candidate-inf"; - candidate.sysSha256 = "candidate-sys"; - candidate.catSha256 = "candidate-cat"; + candidate.infSha256 = canonicalSha256; + candidate.sysSha256 = std::string(64, 'b'); + candidate.catSha256 = std::string(64, 'c'); + PackageInfo mixedCaseCandidate = candidate; + const auto mixDigestCase = [] (std::string* digest) { + for (size_t index = 0; index < digest->size(); index += 2U) { + char& character = (*digest)[index]; + if (character >= 'a' && character <= 'f') { + character = static_cast(character - ('a' - 'A')); + } + } + }; + mixDigestCase(&mixedCaseCandidate.infSha256); + mixDigestCase(&mixedCaseCandidate.sysSha256); + mixDigestCase(&mixedCaseCandidate.catSha256); + PackageInfo mismatchedCandidate = mixedCaseCandidate; + mismatchedCandidate.catSha256.back() = + mismatchedCandidate.catSha256.back() == 'd' ? 'e' : 'd'; + PackageInfo malformedCandidate = candidate; + malformedCandidate.infSha256.back() = 'z'; + if (!SamePackageBytes(candidate, mixedCaseCandidate) || + PackageBytesKey(candidate).empty() || + PackageBytesKey(candidate) != PackageBytesKey(mixedCaseCandidate) || + SamePackageBytes(candidate, mismatchedCandidate) || + SamePackageBytes(candidate, malformedCandidate) || + !PackageBytesKey(malformedCandidate).empty()) { + SetError(&outcome.error, + L"self-test-package-digest-canonicalization", + ERROR_INVALID_DATA, + L"package byte identity is not case-insensitive, canonical, and mismatch-exact"); + return outcome; + } CandidateDisposition disposition = CandidateDisposition::Exact; bool downgrade = true; Error classificationError; @@ -17840,7 +18500,8 @@ Outcome SelfTest() { return outcome; } PackageInfo conflict = candidate; - conflict.infSha256 = "different-inf"; + conflict.infSha256.back() = + conflict.infSha256.back() == 'e' ? 'd' : 'e'; classificationError = {}; if (ClassifyCandidatePackage( candidate, {conflict}, std::nullopt, @@ -17851,7 +18512,8 @@ Outcome SelfTest() { return outcome; } conflict = candidate; - conflict.sysSha256 = "different-sys"; + conflict.sysSha256.back() = + conflict.sysSha256.back() == 'd' ? 'e' : 'd'; classificationError = {}; if (ClassifyCandidatePackage( candidate, {conflict}, std::nullopt, @@ -17862,7 +18524,8 @@ Outcome SelfTest() { return outcome; } conflict = candidate; - conflict.catSha256 = "different-cat"; + conflict.catSha256.back() = + conflict.catSha256.back() == 'd' ? 'e' : 'd'; classificationError = {}; if (ClassifyCandidatePackage( candidate, {conflict}, std::nullopt, @@ -18098,13 +18761,21 @@ Outcome SelfTest() { } PackageInfo priorPackage; priorPackage.publishedName = L"OEM7.INF"; + priorPackage.infSha256 = std::string(64, 'a'); + priorPackage.sysSha256 = std::string(64, 'b'); + priorPackage.catSha256 = std::string(64, 'c'); PackageInfo preservedPackage; preservedPackage.publishedName = L"oem7.inf"; + preservedPackage.infSha256 = std::string(64, 'A'); + preservedPackage.sysSha256 = std::string(64, 'B'); + preservedPackage.catSha256 = std::string(64, 'C'); PackageInfo newPackage; newPackage.publishedName = L"oem9.inf"; - newPackage.sysSha256 = "new-sys"; + newPackage.infSha256 = std::string(64, 'd'); + newPackage.sysSha256 = std::string(64, 'e'); + newPackage.catSha256 = std::string(64, 'f'); PackageInfo changedPackage = preservedPackage; - changedPackage.sysSha256 = "changed"; + changedPackage.sysSha256.back() = 'd'; if (!SamePackageInventory({priorPackage}, {preservedPackage}) || SamePackageInventory({priorPackage}, {preservedPackage, newPackage}) || SamePackageInventory({priorPackage}, {changedPackage}) || @@ -18122,9 +18793,6 @@ Outcome SelfTest() { capturedRoot.publishedInf = L"oem7.inf"; capturedRoot.version = one; capturedRoot.package = priorPackage; - capturedRoot.package.infSha256 = "prior-inf"; - capturedRoot.package.sysSha256 = "prior-sys"; - capturedRoot.package.catSha256 = "prior-cat"; Snapshot capturedRootSnapshot; capturedRootSnapshot.devices.push_back(capturedRoot); Snapshot observedRootSnapshot = capturedRootSnapshot;