Files
minerops/agents/windows/tests/WINDOWS_CREDENTIAL_TRANSITION_VALIDATION.md
T

306 lines
18 KiB
Markdown

# Disposable Windows filesystem-trust validation
Native Windows evidence is required before deployment. The cross-platform
behavioral harness proves deterministic state-machine decisions; it cannot
prove NTFS access checks, directory-handle sharing, junction behavior,
same-directory rename semantics, or Scheduled Task behavior.
## Isolation
Use a newly created, disposable Windows 10/11 or Windows Server VM. Do not join
it to a production domain or connect it to a production MinerOps Hub, database,
or miner network. Never use a production package, URL, Agent ID, transition
token, shared secret, or API3 credential. Copy only the candidate
`agents\windows` directory to the VM and take a snapshot first.
Run 64-bit Windows PowerShell 5.1 as Administrator:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
.\tests\Test-MinerOpsCredentialTransition.Windows.ps1 `
-AcknowledgeDisposableVm
```
Record the exact pass count printed by the candidate harness. Do not claim the
native suite passed unless this command actually completed on Windows. Save the
complete transcript plus:
```powershell
Get-ComputerInfo | Select-Object WindowsProductName, WindowsVersion, OsBuildNumber
$PSVersionTable
```
The native primitive suite verifies:
- protected credential, enrollment, installer, and trust-blocker files are
created with their final `FileSecurity`, held exclusively, and read back
through the same still-empty handle before any content is written;
- the directory and credential-file DACLs deny standard-user read, create,
delete, rename, and replacement-swap operations;
- ACL setup or read-back failure cannot leave secret-bearing content;
- a pre-created pathname remains unchanged when `CreateNew` fails;
- an insecure artifact is repaired through an exclusive handle with explicit
security rights, then rejected; inability to acquire those rights fails
closed without a path-based fallback;
- the durable blocker exists before an ACL-repaired artifact could be
reconsidered, and remains effective on the next check;
- quarantine evidence is unreadable to the standard user;
- the two-rename install leaves the new active file and a non-authoritative
prior remnant, and an active-missing install does not invent prior authority;
- a directory junction is rejected;
- a permissive directory is hardened, gets an external durable blocker, and
cannot continue in that process or an ordinary rerun;
- pre-repair standard-user file and directory handles are exercised after ACL
repair while automatic transition remains blocked regardless of whether the
handle operation succeeds;
- hard-linked enrollment, runtime, and backup targets are rejected; and
- a real harmless expected Scheduled Task is positively verified for exact
`Running` and `Ready`, while missing, query failure, disabled, malformed,
wrong-action, and wrong-principal cases fail closed.
## Full-kernel-boot epoch matrix
The source uses the canonical value
`windows-full-boot-event-v1:<16 lowercase hex digits>`. The payload is the
Event Log `RecordId` anchor of the newest System-log event from
`Microsoft-Windows-Kernel-Boot`, event ID 27, whose namespace-aware XML payload
contains exactly one canonical `BootType` value equal to `0`. `ProviderName`,
`Id`, nullable `RecordId`, and nullable `Version` must have the real typed
EventLogRecord shapes; supported versions are 0 and 1. The record ID is not an
immutable boot GUID. A reset requires a valid current anchor strictly greater
than the stored anchor. Equal, lower/restored/reused, missing, and ambiguous
anchors fail closed. The implementation deliberately does not use wall-clock
time or the Windows `BootId` counter: Fast Startup is a hibernated kernel
restoration and must not authorize reset merely because Windows reports a new
startup sequence.
On a disposable VM, dot-source the transition script and record
`Get-MinerOpsBootIdentifier` before and after every row. Preserve the raw event
object and `ToXml()` output from `Get-WinEvent`. For each event, record the
runtime types and values of `ProviderName`, `Id`, `RecordId`, and `Version`, the
root namespace, and the `System/EventData/Data[@Name='BootType']` shape.
| Transition | Required canonical identity |
| --- | --- |
| Change system clock within the running session | unchanged |
| Change timezone within the running session | unchanged |
| Sleep and resume | unchanged |
| Hibernate and resume, where supported | unchanged |
| Shutdown and power-on with Fast Startup, where supported | unchanged |
| Restart from the Windows power menu | strictly greater record anchor |
For the Fast Startup row, independently confirm that Windows restored kernel
session 0 and that the latest event 27 has a nonzero `BootType`; the newest
full-boot (`BootType=0`) event record must remain the same. For the Restart row,
confirm a new exact `BootType=0` event and a new canonical identity. Also test a
missing System log/provider event, duplicated `BootType`, malformed XML value,
wrong namespace, unsupported/null version, wrong provider/event ID,
zero/malformed record ID, and query access failure; every case must fail
closed.
Use a separate disposable snapshot for log-retention behavior. Preserve the
pre-clear stored anchor and evidence first, then clear only the disposable VM's
System log. Verify that missing history cannot authorize reset. After a full
Restart, if Event Log numbering restarts at or below the stored anchor, verify
that automatic reset remains blocked and requires operator intervention. Also
restore a snapshot exposing only an older valid full-boot event and confirm the
lower record ID is rejected. Do not add a timestamp fallback for any row. A
native result is required because EventLogRecord property types, provider
namespace/version, log-clear numbering, and power-state behavior cannot be
proven by the Linux harness.
## Loopback workflow fixture
Use a fresh snapshot for full workflow testing. All values must be disposable.
1. Create a test `C:\MinerOpsAgent\agent-config.json` containing random Agent,
Site, and Zone UUIDs plus placeholder secrets.
2. Place a harmless `MinerOpsAgentService.ps1` in the target and register
`MinerOpsAgent` with the exact installer-controlled SYSTEM/highest action:
`powershell.exe -NoProfile -ExecutionPolicy Bypass -File
"C:\MinerOpsAgent\MinerOpsAgentService.ps1"`.
3. Serve a loopback-only mock at `http://127.0.0.1:<port>`. Its
`POST /api/agent/credential-transition` handler records only the token hash
and submitted `agent_secret_hash`, returns the fixed disposable
Agent/Site/Zone identity, returns the same response for the same token and
hash, and rejects a changed hash.
4. Build a local package with that loopback origin and a random disposable
43-character token. Never download from or call a real Hub.
5. For each case, restore the snapshot or recreate the fixture. Run once with
the task initially running and once with it intentionally stopped.
The child-process shape for hard termination is:
```powershell
$checkpoint = 'pending-written'
$code = @"
. 'C:\validation\agents\windows\Upgrade-MinerOpsAgentCredential.ps1'
Invoke-MinerOpsAgentCredentialTransition `
-TargetRoot 'C:\MinerOpsAgent' `
-PackageRoot 'C:\validation\agents\windows' `
-ScheduledTaskName 'MinerOpsAgent' `
-CheckpointAction { param(`$name) if (`$name -eq '$checkpoint') { Stop-Process -Id `$PID -Force } }
"@
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($code))
Start-Process powershell.exe `
-ArgumentList '-NoProfile','-EncodedCommand',$encoded -Wait
```
After termination, rerun normally without deleting, repairing, or editing any
artifact. Every retry request must use the same token and
`agent_secret_hash`; no retry may generate a replacement secret.
## Hard-termination matrix
| Checkpoint | State before rerun | Required rerun result |
| --- | --- | --- |
| `directory-repair-blocker-written` | external protected blocker durable before repair | no artifact is trusted; every ordinary invocation remains blocked |
| `directory-trust-established` | hardened directory; external blocker retained | pre-repair recovery/pending/remnants are quarantined without parsing; even a clean repair cannot continue in this process |
| `directory-containment-scan-complete` | initial known-name scan finished; external blocker retained | a late recovery/pending file never becomes authority on this or later ordinary invocations |
| `trust-reset-authorized` | changed full-boot epoch and strict original task intent are durable; blocker retained | approved retry preserves that intent and repeats publication; it cannot delete early |
| `trust-reset-runtime-published` | fresh package runtime is durable; blocker is `runtime-published` | approved retry resumes credentials/config/task convergence with the blocker retained |
| `quarantine-blocker-written` | blocker durable; insecure authoritative artifact may remain | second run never parses or redeems it |
| `artifact-moved-to-quarantine` | authority name absent; evidence is beneath quarantine | evidence remains non-authoritative and the quarantine directory is reverified |
| `quarantine-directory-verified` | evidence is under the protected non-traversable quarantine directory | rerun remains blocked and never reads or ACL-repairs evidence contents |
| `recovery-written` | protected recovery; original task state retained | exact saved token/secret is reused |
| `task-stopped` | recovery protected; task stopped; no request yet | transition completes and restores original task intent |
| `server-committed` | server may have committed; task stopped | idempotent same-hash redemption completes |
| `pending-written` | protected matching recovery and pending | pending is validated and reused, not overwritten |
| `before-replace` | active legacy config plus pending | first rename may proceed; rerun converges |
| `active-moved-to-prior` | active missing; matching pending and legacy prior present | prior is never promoted; pending becomes active |
| `pending-moved-to-active` | committed active plus prior; pending absent | active is verified; prior is cleanup-only |
| `after-replace` | same as prior row | active is verified before task restoration |
| `task-restored` | active and final task state verified | retry verifies again and cleans up |
| `before-trust-blocker-removal` | active config and final strict task state verified; external blocker still present | only an explicitly approved parent retry may verify again and remove the blocker |
| `before-recovery-cleanup` | active/task verified; recovery remains last | retry uses recovery and completes cleanup |
For each row inspect ACLs and task state without displaying secret contents:
```powershell
Get-Acl C:\MinerOpsAgent | Format-List
Get-Acl C:\MinerOpsAgent\agent-credential-transition.recovery.json `
-ErrorAction SilentlyContinue | Format-List
Get-Acl C:\MinerOpsAgent\agent-config.json.pending `
-ErrorAction SilentlyContinue | Format-List
Get-ScheduledTask -TaskName MinerOpsAgent | Select-Object TaskName, State
```
## Provenance and quarantine cases
Run these as separate restored-snapshot cases:
1. **Malicious recovery, three invocations.** Stop after `recovery-written`, add
a Users read/write ACE to recovery, replace its secret and
`original_task_running`, and run twice. The first run must create a blocker,
quarantine without parsing, and send no request. The second must remain
blocked, send no request, keep the real original task state, and omit the
attacker secret from all output. A third ordinary run must remain blocked.
2. **ACL-repair laundering attempt.** Terminate at
`quarantine-blocker-written`, then repair the still-authoritative recovery
file ACL by hand without deleting the blocker. The next run must quarantine
it without reading or further ACL mutation and must not redeem it. This
proves ACL correctness cannot override the durable provenance decision.
3. **Malicious pending, three invocations.** Stop after `pending-written`, add a
Users ACE and alter the credential/Agent data. All runs must send no new
request and must never promote pending.
4. **Permissive directory with attacker state.** Add Users Modify to the target
directory, place recovery and pending before repair, and run. Both artifacts
must be quarantined, a blocker retained, and no request sent. A hard stop at
`directory-trust-established` must have the same second-run result.
5. **Quarantine preservation.** A later successful, separately authorized
transition must not silently delete unresolved blocker or evidence files.
Operator review must remove them only after invalidating the exposed token
and obtaining a new package.
6. **Late arrival after scan.** At `directory-containment-scan-complete`, use a
handle opened before repair to attempt creation/rename of recovery and
pending names, including a file whose ACL now looks correct. The current,
second, and third ordinary runs must send no request and perform no
activation regardless of the handle operation's Windows result.
7. **Trust-epoch reset.** Use Restart, confirm the canonical full-kernel-boot
event identity changed, clear suspicious transition state only under the
approved procedure, and run with
`-ApproveFilesystemTrustResetAfterReboot`. Confirm fresh runtime publication
occurs while the external blocker exists, old runtime is never a rollback
source, the task remains stopped until transition verification, and the
blocker remains through credential/config convergence and final strict task
verification. It may disappear only after all of those parent checks pass.
The same switch before Restart must fail. Repeat after clock/timezone change,
sleep, hibernate, and Fast Startup; each must still fail as the same epoch.
8. **Direct installer reset attempts.** With a real external blocker present,
invoke `install.ps1 -ApproveFilesystemTrustResetAfterReboot` directly for a
successful publication and an injected publication failure. Repeat with the
task disabled, `Unknown`, `Queued`, wrong action, and wrong principal. The
blocker must remain in every case. Then run the complete parent workflow and
prove deletion occurs only after its final convergence checks.
In every quarantine case, test standard-user reads against every
`minerops-quarantine-credential-transition-*.evidence` file. Access must be
denied, and neither console nor error transcripts may contain its contents.
## Hard-link cases
On NTFS, create a second name for each candidate with `New-Item -ItemType
HardLink`. Test attacker-created enrollment pending, recovery, and final config
paths; installed runtime destinations; and rollback sources/destinations. Every
case must fail before secret write, redemption, publication, or restore. Verify
that no attacker-readable alias contains a permanent credential and that a
failed `CreateNew` does not delete or adopt the pre-existing file.
## Strict Scheduled Task matrix
Use the exact installer-controlled action and principal as the valid fixture.
Run both durable intents through `Running`, `Ready`, missing, provider/query
exception, access denied where practical, null/empty state, `Disabled`,
`Unknown`, `Queued`, malformed object/definition, multiple results, wrong
executable/script arguments, and wrong principal. Only running plus `Running`
and stopped plus `Ready` succeed. Stopped plus `Running` must issue stop and
then positively verify `Ready`.
Capture the actual provider types for `TaskPath`, `State`, `Settings.Enabled`,
`Actions`, action `Execute`/`Arguments`, and principal `UserId`, `LogonType`, and
`RunLevel`. Verify root `TaskPath` is an actual string equal to `\` and Enabled
is an actual Boolean `$true`. Missing, null, empty, array, numeric, and truthy
string substitutions must fail; provider enum normalization is allowed only for
`State`, `LogonType`, and `RunLevel`.
After the server-commit checkpoint, inject final task verification failure.
Recovery must remain and no success output or cleanup may occur. Repair the task
and rerun; the recorded token and `agent_secret_hash` must be identical and the
retry must converge without generating a new secret.
## Active-missing and replacement cases
Run all of these with a cryptographically matching protected recovery record:
- active missing, protected matching pending present, no prior (legacy
`ReplaceFile` partial state): converges with the same token and secret;
- active missing, matching pending and valid legacy prior present (new first
rename completed): converges through pending; prior is never authority;
- committed active and valid prior present, pending absent (new second rename
completed): verifies active and cleans prior last;
- failure before the first rename: active and pending remain, rerun converges;
- failure between the two renames: active is absent, pending and prior remain,
rerun converges;
- failure immediately after pending becomes active: active and prior remain,
rerun verifies active before cleanup;
- active missing with no recovery or with unknown pending: fails closed;
- active missing with malformed pending: fails before redemption;
- active missing with mismatched Agent, secret, or secret hash: fails before
redemption; and
- active missing with only a stale prior: fails closed and never promotes it.
For every successful active-missing case, confirm exact Agent/Hub/Site/Zone
identity, the same token and secret hash on every request, active-file DACL,
and final Scheduled Task intent. Initially running must end running;
intentionally stopped must remain stopped.
## Completion and cleanup
At successful termination, recovery, pending, prior, and consumed enrollment
files are absent; unresolved quarantine/blocker evidence is never silently
removed. Console/error transcripts contain neither token nor generated secret.
Record any disposable account, Scheduled Task, junction, directory, or mock
service left by an interrupted validation, remove only those validation
resources, then restore the snapshot or destroy the VM.