feat(windows): add recoverable existing-agent credential upgrade

This commit is contained in:
lussierj committed 2026-10-14 13:50:19 +00:00
1 parent 3cfb0d1abe
commit 97a0139e3d
25 files changed
+11122 -108

No files matched your search

File diff suppressed because it is too large. Load diff
+2
View File
@@ -204,9 +204,11 @@ $testButton.Add_Click({
}
$hub = [string]$config.hub_url
$agentId = [string]$config.agent_id
$secret = [string]$config.agent_secret
$headers = @{
"X-MinerOps-Agent-ID" = $agentId
"X-MinerOps-Agent-Secret" = $secret
}
+124 -3
View File
@@ -74,9 +74,122 @@ credentials in screenshots, chat, tickets, logs, or source control.
### Existing Agent upgrade
For an already enrolled/configured Agent, do not run the enrollment
bootstrapper. Copy the new canonical Agent files to a temporary directory and
run install.ps1 from an elevated Windows PowerShell session.
An existing legacy Agent must receive a per-Agent credential before the global
shared-secret compatibility path can be retired for its identity. In Agent
Management, use **Upgrade credential** on that Agent. Download the resulting
Windows package, transfer it only to that Agent computer, and run:
.\Upgrade-MinerOpsAgentCredential.ps1
The authorization is bound server-side to the existing Agent ID and its
current Site and Agent Zone. The script validates the local Agent ID, upgrades
the canonical runtime, stops the main Agent task, generates the permanent
secret locally, and recoverably replaces only the credential and durable routing
fields in `agent-config.json` using protected same-directory pending and prior
names. Existing API3 credentials, polling settings,
network ranges, state, logs, and backups are preserved. Before any secret is
written, the script creates and verifies a protected recovery file containing
the original Scheduled Task state, exact transition token, and exact locally
generated credential. The Hub accepts an ambiguous retry only for that same
token and credential.
The Agent directory is a filesystem security boundary. The transition rejects
junctions/reparse points, verifies trusted ancestors, denies standard users
create/delete/rename rights, and revalidates the boundary before each rename.
If an existing Agent directory is discovered insecure, the script first writes
`C:\MinerOpsAgent.minerops-filesystem-trust.blocked-<random>.state` under the trusted
parent, then hardens the directory only to contain the incident. The blocker is
outside the contaminated namespace because changing a DACL does not revoke
file or directory handles opened before repair. The same process never redeems
a token, creates or activates a permanent credential, runs the installer, or
removes that blocker. Ordinary second, third, and later invocations remain
blocked even when every current ACL looks correct.
Recovery, pending, replacement remnants, and late-arriving artifacts are
non-authoritative while the external blocker exists. Known transition state is
quarantined without parsing. A correct filename, syntax, or ACL cannot promote
it on a later run. Invalidate any possibly exposed authorization and retain or
remove evidence only under the approved incident procedure.
### Filesystem trust-epoch recovery
Use this procedure only after confirming the blocker was created by the
containment event and preserving required evidence:
1. Do not delete or edit the external blocker. Do not run an ordinary retry.
2. Use **Restart** to perform a full Windows kernel boot. Shutdown with Fast
Startup is not sufficient: Microsoft documents Fast Startup as hibernating
and restoring kernel session 0. The script identifies the kernel epoch by
the Event Log record anchor of the newest
`Microsoft-Windows-Kernel-Boot` event 27 whose `BootType` is exactly `0`.
Provider, event ID, record ID, and supported event version come from typed
EventRecord properties; namespace-aware XML selection reads only the
`BootType` payload. The new full-boot record ID must be strictly greater
than the stored record ID. Equality, an older/restored record, log clearing
or numbering reset, missing history, duplication, malformed XML, and an
unsupported provider schema all fail closed and require safe operator
intervention. There is no timestamp fallback. The anchor is unchanged by
clock or timezone changes, sleep, hibernation, and Fast Startup. See
Microsoft's [system power-state documentation](https://learn.microsoft.com/windows/win32/power/system-power-states)
and [Fast Startup support guidance](https://learn.microsoft.com/troubleshoot/windows-client/setup-upgrade-and-drivers/fast-startup-causes-system-hibernation-shutdown-fail).
3. For a credential transition, use a fresh transition package if any recovery,
pending, remnant, quarantine, or internal blocker existed. Remove those
artifacts only under the approved incident procedure after invalidating the
old authorization.
4. From the reviewed package, run:
.\Upgrade-MinerOpsAgentCredential.ps1 `
-ApproveFilesystemTrustResetAfterReboot
The script verifies that Windows boot identity changed, rescans the protected
namespace, positively verifies and stops the expected Scheduled Task, and asks the
installer to replace managed runtime without backing up or restoring any
pre-reset runtime. The installer leaves both the Agent task and external
blocker untouched after publication; it has no blocker-deletion path. Before
its first fallible stop/install step, the parent durably records the strictly
validated original task intent as `reset-authorized`; after installer
success it advances the blocker to `runtime-published`. It then verifies the
config against the package/server identity, commits the credential, and
restores the recorded task intent. Only the parent removes the blocker,
after active config and final task state have both been positively verified
and non-recovery transition cleanup has succeeded. Protected recovery stays
until after blocker deletion so an interrupted final cleanup remains safe.
5. For a genuinely new enrollment, preserve evidence and use the approved
protected-recreation incident procedure to remove the contaminated namespace
and its external blocker, then let `Enroll-MinerOpsAgent.ps1` stage a new
protected directory. Enrollment has no in-script blocker-deletion path; its
explicit compatibility switch cannot override a retained blocker.
The explicit switch without a strictly newer canonical full-kernel-boot anchor
fails.
Any suspicious transition artifact, failed runtime publication, failed
credential convergence, invalid final task, or interrupted cleanup retains the
blocker. Runtime publication alone never authorizes deletion. Protected
directory recreation is the alternative when the retained namespace cannot be
verified.
If the script is interrupted after the Hub commits, rerun the same extracted
package without deleting or editing `agent-credential-transition.recovery.json`
or `agent-config.json.pending`. A protected, matching pending config is resumed;
an insecure, malformed, or mismatched artifact fails closed. The original task
intent is durable: an Agent which was running before the first attempt ends
running, while an intentionally stopped Agent remains stopped. Success is not
reported and transition artifacts are not removed until both the active config
and expected final task state are verified. The verifier requires exactly one
enabled root `MinerOpsAgent` task, the SYSTEM service-account/highest principal,
the installed Windows PowerShell action and service-script arguments, and an
exact final state of `Running` for running intent or `Ready` for stopped intent.
Missing, query failure, access denied, malformed, multiple, disabled, queued,
unknown, wrong-action, and wrong-principal results all fail closed and retain
recovery.
After a successful transition, the existing Agent ID, Site, and Zone are
unchanged. Explicit Agent ID plus the new per-Agent secret is required, and the
legacy shared secret is permanently disabled for that identity.
For later code-only upgrades of an Agent that already has a per-Agent
credential, copy the new canonical Agent files to a temporary directory and run
install.ps1 from an elevated Windows PowerShell session.
The installer copies only managed runtime code to C:\MinerOpsAgent and
preserves agent-config.json, agent-state.json, logs\, backups, probe output,
@@ -90,6 +203,14 @@ again. A previously stopped Agent remains stopped. Before replacing runtime
files, the installer copies them to a timestamped backup under
C:\MinerOpsAgent\backups.
Runtime sources, installed destinations, active config, and rollback copies are
rejected when they are reparse points or have more than one hard link. A normal
trusted upgrade creates a fresh random backup directory and never adopts an
existing backup destination. During an explicit post-reboot trust reset, old
runtime is never copied into backup or restored; fresh package content replaces
it while the external blocker remains, and the Agent task stays stopped for
credential verification.
A failed deployment restores managed files, removes tasks created by that
failed attempt, and restores the main Agent's prior running state.
File diff suppressed because it is too large. Load diff
+1039 -15
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,247 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Operation,
[Parameter(Mandatory = $true)][string]$InputPath,
[string]$DestinationPath = "",
[ValidateSet("None", "Path")][string]$BackupMode = "None",
[string]$BackupPath = "",
[Parameter(Mandatory = $true)][string]$ResultPath,
[Parameter(Mandatory = $true)][string]$SupportPath
)
$ErrorActionPreference = "Stop"
. $SupportPath
$sourceCreatedByCaller = $false
$sourceReadVerified = $false
$sourceModifyVerified = $false
$sourceRenameVerified = $false
$sourceDeleteAccessVerified = $false
$sourceReplaceAccessVerified = $false
$replaceInvoked = $false
$sourceExistedBeforeAttack = $null
$destinationExistedBeforeAttack = $null
$backupExistedBeforeAttack = $null
$inputFullPath = ""
$destinationFullPath = ""
$backupFullPath = ""
$backupArgumentWasNativeNull = $false
function Get-ProbeMetadata {
param(
[Parameter(Mandatory = $true)][string]$Status,
[Exception]$Exception
)
$inputOwnedByCaller = $false
if ([IO.File]::Exists($InputPath)) {
try {
$callerSid = [Security.Principal.WindowsIdentity]::GetCurrent().User
$ownerSid = [IO.File]::GetAccessControl($InputPath).GetOwner(
[Security.Principal.SecurityIdentifier]
)
$inputOwnedByCaller = $callerSid.Equals($ownerSid)
}
catch {
$inputOwnedByCaller = $false
}
}
$details = if ($null -ne $Exception) {
Get-MinerOpsNativeExceptionDetails -Exception $Exception
}
else { $null }
return [ordered]@{
status = $Status
operation = $Operation
# Retain these two outer-exception fields for compatibility with
# earlier native-harness result readers.
exception_type = if ($null -ne $Exception) {
$Exception.GetType().FullName
}
else {
""
}
hresult = if ($null -ne $details) { $details.OuterHResult } else { 0 }
outer_exception_type = if ($null -ne $details) {
$details.OuterExceptionType
}
else { "" }
outer_hresult = if ($null -ne $details) {
$details.OuterHResult
}
else { 0 }
leaf_exception_type = if ($null -ne $details) {
$details.LeafExceptionType
}
else { "" }
leaf_hresult = if ($null -ne $details) {
$details.LeafHResult
}
else { 0 }
native_error = if ($null -ne $details) {
$details.NativeError
}
else { $null }
exception_chain_types = if ($null -ne $details) {
@($details.ExceptionChainTypes)
}
else { @() }
exception_chain_truncated = if ($null -ne $details) {
[bool]$details.ExceptionChainTruncated
}
else { $false }
source_exists = [IO.File]::Exists($InputPath)
destination_exists = if (
[string]::IsNullOrWhiteSpace($DestinationPath)
) {
$null
}
else {
[IO.File]::Exists($DestinationPath)
}
input_owned_by_caller = $inputOwnedByCaller
source_created_by_caller = $sourceCreatedByCaller
source_read_verified = $sourceReadVerified
source_modify_verified = $sourceModifyVerified
source_rename_verified = $sourceRenameVerified
source_delete_access_verified = $sourceDeleteAccessVerified
source_replace_access_verified = $sourceReplaceAccessVerified
input_full_path = $inputFullPath
destination_full_path = $destinationFullPath
backup_mode = $BackupMode
backup_full_path = $backupFullPath
backup_argument_was_native_null = $backupArgumentWasNativeNull
source_existed_before_attack = $sourceExistedBeforeAttack
destination_existed_before_attack = $destinationExistedBeforeAttack
backup_existed_before_attack = $backupExistedBeforeAttack
replace_overload = "System.IO.File.Replace(String,String,String)"
replace_invoked = $replaceInvoked
}
}
function Write-ProbeResult {
param([Parameter(Mandatory = $true)]$Value)
$json = $Value | ConvertTo-Json -Compress
[IO.File]::WriteAllText($ResultPath, $json)
}
$exception = $null
$status = "succeeded"
try {
switch ($Operation) {
"read" { [void][IO.File]::ReadAllText($InputPath) }
"create" { [IO.File]::WriteAllText($InputPath, "untrusted") }
"modify" { [IO.File]::WriteAllText($InputPath, "untrusted") }
"delete" { [IO.File]::Delete($InputPath) }
"rename" { [IO.File]::Move($InputPath, $DestinationPath) }
"prepare_swap" {
if ([IO.File]::Exists($InputPath)) {
throw [ArgumentException]::new(
"swap source unexpectedly existed before caller creation"
)
}
$renameProof = "$InputPath.rename-proof-$([guid]::NewGuid().ToString('N'))"
[IO.File]::WriteAllText(
$InputPath,
"attacker-controlled-swap-source"
)
$sourceCreatedByCaller = $true
$sourceReadVerified = [IO.File]::ReadAllText($InputPath) -ceq
"attacker-controlled-swap-source"
[IO.File]::AppendAllText($InputPath, "-modified")
$sourceModifyVerified = [IO.File]::ReadAllText($InputPath) -ceq
"attacker-controlled-swap-source-modified"
[IO.File]::Move($InputPath, $renameProof)
[IO.File]::Move($renameProof, $InputPath)
$sourceRenameVerified = [IO.File]::Exists($InputPath) -and
-not [IO.File]::Exists($renameProof)
$requiredRights =
[Security.AccessControl.FileSystemRights]::ReadData -bor
[Security.AccessControl.FileSystemRights]::WriteData -bor
[Security.AccessControl.FileSystemRights]::Delete -bor
[Security.AccessControl.FileSystemRights]::ChangePermissions -bor
[Security.AccessControl.FileSystemRights]::Synchronize
$rightsProbe = [IO.FileStream]::new(
$InputPath,
[IO.FileMode]::Open,
$requiredRights,
[IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete,
4096,
[IO.FileOptions]::None
)
try {
$sourceDeleteAccessVerified = $true
$sourceReplaceAccessVerified = $true
}
finally {
$rightsProbe.Dispose()
}
}
"swap" {
$inputFullPath = [IO.Path]::GetFullPath($InputPath)
$destinationFullPath = [IO.Path]::GetFullPath($DestinationPath)
if ($inputFullPath.Equals(
$destinationFullPath,
[StringComparison]::OrdinalIgnoreCase
)) {
throw [ArgumentException]::new(
"swap source and destination are the same path"
)
}
$sourceExistedBeforeAttack = [IO.File]::Exists($inputFullPath)
$destinationExistedBeforeAttack =
[IO.File]::Exists($destinationFullPath)
if (-not $sourceExistedBeforeAttack) {
throw [IO.FileNotFoundException]::new(
"swap source does not exist",
$inputFullPath
)
}
if (-not $destinationExistedBeforeAttack) {
throw [IO.FileNotFoundException]::new(
"swap destination does not exist",
$destinationFullPath
)
}
if ($BackupMode -ceq "None") {
if (-not [string]::IsNullOrEmpty($BackupPath)) {
throw [ArgumentException]::new(
"backup path was supplied while backup mode is None"
)
}
# A bare PowerShell $null is coerced to String.Empty for this
# .NET string parameter. NullString is the explicit native
# null required to request no ReplaceFile backup.
$backupArgumentWasNativeNull = $true
}
else {
throw [ArgumentException]::new(
"backup-producing swap fixtures are prohibited because " +
"they can expose protected destination content"
)
}
$replaceInvoked = $true
[IO.File]::Replace(
$inputFullPath,
$destinationFullPath,
[NullString]::Value
)
}
default { throw [ArgumentException]::new("unknown operation") }
}
}
catch {
$exception = $_.Exception
$status = Get-MinerOpsNativeExceptionStatus `
-Operation $Operation -Exception $exception
}
$result = Get-ProbeMetadata -Status $status -Exception $exception
Write-ProbeResult -Value $result
exit (Get-MinerOpsNativeStatusExitCode -Status $status)
@@ -0,0 +1,475 @@
$ErrorActionPreference = "Stop"
function Get-MinerOpsNativeGetItemState {
$effective = Get-Command Get-Item -ErrorAction Stop
$function = if (
$effective.CommandType -eq
[Management.Automation.CommandTypes]::Function
) {
$effective
}
else {
$null
}
return [pscustomobject]@{
FunctionExisted = $null -ne $function
FunctionScriptBlock = if ($null -ne $function) {
$function.ScriptBlock
}
else {
$null
}
EffectiveCommandType = [string]$effective.CommandType
EffectiveName = [string]$effective.Name
EffectiveModuleName = [string]$effective.ModuleName
EffectiveSource = [string]$effective.Source
EffectiveDefinition = [string]$effective.Definition
}
}
function Assert-MinerOpsNativeGetItemState {
param(
[Parameter(Mandatory = $true)]$Expected,
[string]$Context = "Get-Item state check"
)
$actual = Get-MinerOpsNativeGetItemState
if ([bool]$actual.FunctionExisted -ne [bool]$Expected.FunctionExisted) {
throw "$Context changed the global Get-Item function's existence."
}
if (
[bool]$Expected.FunctionExisted -and
(
-not [object]::ReferenceEquals(
$actual.FunctionScriptBlock,
$Expected.FunctionScriptBlock
) -or
$actual.FunctionScriptBlock.ToString() -cne
$Expected.FunctionScriptBlock.ToString()
)
) {
throw "$Context changed the global Get-Item function's ScriptBlock."
}
foreach ($property in @(
"EffectiveCommandType",
"EffectiveName",
"EffectiveModuleName",
"EffectiveSource",
"EffectiveDefinition"
)) {
if ([string]$actual.$property -cne [string]$Expected.$property) {
throw "$Context changed effective Get-Item resolution ($property)."
}
}
}
function Invoke-MinerOpsNativeGetItemTrace {
param([Parameter(Mandatory = $true)][scriptblock]$Action)
$before = Get-MinerOpsNativeGetItemState
$observedGetItemPaths = [System.Collections.Generic.List[string]]::new()
$invokeWithLocalHook = {
param(
[Parameter(Mandatory = $true)][scriptblock]$TraceAction,
[Parameter(Mandatory = $true)]$ObservedPaths
)
# Use a child scope because PowerShell can continue resolving a
# removed active function until the scope that defined it exits.
# Exiting this child scope makes cleanup deterministic before the
# caller-state assertion runs.
Set-Item -LiteralPath "Function:\local:Get-Item" -Value {
[CmdletBinding(DefaultParameterSetName = "Path")]
param(
[Parameter(Position = 0, ParameterSetName = "Path")]
[string[]]$Path,
[Parameter(Mandatory = $true, ParameterSetName = "LiteralPath")]
[Alias("PSPath")]
[string[]]$LiteralPath,
[switch]$Force
)
$observed = if ($PSCmdlet.ParameterSetName -eq "LiteralPath") {
$LiteralPath
}
else {
$Path
}
foreach ($candidate in @($observed)) {
$ObservedPaths.Add([string]$candidate)
}
Microsoft.PowerShell.Management\Get-Item @PSBoundParameters
}
try {
& $TraceAction | Out-Null
}
finally {
Remove-Item -LiteralPath "Function:\local:Get-Item" -Force `
-ErrorAction SilentlyContinue
}
}
try {
& $invokeWithLocalHook $Action $observedGetItemPaths
return @($observedGetItemPaths)
}
finally {
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "Invoke-MinerOpsNativeGetItemTrace cleanup"
}
}
function Get-MinerOpsNativeWin32Error {
param([Parameter(Mandatory = $true)][int]$HResult)
# HRESULT_FROM_WIN32 values have the 0x8007 facility prefix. Do not treat
# the low word of an arbitrary CLR or PowerShell HRESULT as a Win32 error.
$unsignedHResult = [int64]$HResult -band 4294967295L
if (
($unsignedHResult -band 4294901760L) -eq 2147942400L
) {
return [int]($unsignedHResult -band 65535L)
}
return $null
}
function Get-MinerOpsNativeExceptionDetails {
param([Parameter(Mandatory = $true)][Exception]$Exception)
$maximumDepth = 32
$visited = [System.Collections.Generic.List[Exception]]::new()
$chainTypes = [System.Collections.Generic.List[string]]::new()
$current = $Exception
$leaf = $Exception
$truncated = $false
for ($depth = 0; $depth -lt $maximumDepth; $depth++) {
if ($null -eq $current) { break }
$alreadyVisited = $false
foreach ($candidate in $visited) {
if ([object]::ReferenceEquals($candidate, $current)) {
$alreadyVisited = $true
break
}
}
if ($alreadyVisited) {
$truncated = $true
break
}
$visited.Add($current)
$chainTypes.Add($current.GetType().FullName)
$leaf = $current
$current = $current.InnerException
}
if ($null -ne $current) {
$truncated = $true
}
$nativeError = Get-MinerOpsNativeWin32Error -HResult ([int]$leaf.HResult)
return [pscustomobject]@{
OuterException = $Exception
OuterExceptionType = $Exception.GetType().FullName
OuterHResult = [int]$Exception.HResult
LeafException = $leaf
LeafExceptionType = $leaf.GetType().FullName
LeafHResult = [int]$leaf.HResult
NativeError = $nativeError
ExceptionChainTypes = @($chainTypes)
ExceptionChainTruncated = $truncated
}
}
function Get-MinerOpsNativeExceptionStatus {
param(
[Parameter(Mandatory = $true)][string]$Operation,
[Parameter(Mandatory = $true)][Exception]$Exception
)
$details = Get-MinerOpsNativeExceptionDetails -Exception $Exception
$meaningfulException = $details.LeafException
$nativeError = $details.NativeError
if ($details.ExceptionChainTruncated) {
return "unexpected_failure"
}
if ($meaningfulException -is [UnauthorizedAccessException]) {
return "security_denied"
}
if (
$meaningfulException -is [ArgumentException] -or
$meaningfulException -is [IO.FileNotFoundException] -or
$meaningfulException -is [IO.DirectoryNotFoundException] -or
$meaningfulException -is [IO.DriveNotFoundException] -or
$meaningfulException -is [IO.PathTooLongException] -or
$meaningfulException -is [NotSupportedException]
) {
return "fixture_error"
}
if (
$meaningfulException -is [IO.IOException] -and
$null -ne $nativeError
) {
if ([int]$nativeError -eq 5) {
return "security_denied"
}
if ($Operation -ceq "swap" -and [int]$nativeError -eq 1175) {
# ReplaceFileW documents ERROR_UNABLE_TO_REMOVE_REPLACED as
# retaining both original names. The caller must still prove every
# security and identity postcondition before treating this as a
# passing denial.
return "security_denied_requires_postconditions"
}
if ([int]$nativeError -eq 17) {
# ReplaceFile requires source and destination on the same volume.
# This says nothing about the protected target's access boundary.
return "api_precondition_failure"
}
}
return "unexpected_failure"
}
function Get-MinerOpsNativeStatusExitCode {
param([Parameter(Mandatory = $true)][string]$Status)
switch ($Status) {
"succeeded" { return 0 }
"security_denied" { return 5 }
"fixture_error" { return 6 }
"unexpected_failure" { return 7 }
"security_denied_requires_postconditions" { return 8 }
"api_precondition_failure" { return 9 }
default { throw "Unknown standard-user helper status '$Status'." }
}
}
function Get-MinerOpsNativeSwapFixtureStatus {
param([Parameter(Mandatory = $true)]$Fixture)
$fixtureFailures = [System.Collections.Generic.List[string]]::new()
$requiredFixtureChecks = [ordered]@{
SourceExists = "source is missing"
TargetExists = "target is missing"
PathsDistinct = "source and target are the same path"
SourceCreatedByStandardUser = `
"source was not created by the standard-user process"
SourceOwnedByStandardUser = `
"standard user does not own the source"
SourceReadable = "standard user cannot read the source"
SourceModifiable = "standard user cannot modify the source"
SourceRenameable = "standard user cannot rename the source"
SourceDeleteCapable = `
"standard user does not have required delete access to the source"
SourceReplaceAccessVerified = `
"standard user lacks source access required by ReplaceFile"
TargetAclTrusted = "target ACL was not trusted before attack"
TargetOwnerTrusted = "target owner was not trusted before attack"
TargetIdentityCaptured = "target identity was not captured"
SourceIdentityCaptured = "source identity was not captured"
FileIdentitiesDistinct = "source and target resolve to the same file"
BackupArgumentValid = "backup argument is invalid"
BackupPathsDistinct = `
"backup path is not distinct from source and target"
BackupExpectedStateVerified = `
"backup pre-attack state was not verified"
BackupConfidentialitySafe = `
"backup choice could expose protected target content"
}
foreach ($entry in $requiredFixtureChecks.GetEnumerator()) {
if (-not [bool]$Fixture.($entry.Key)) {
$fixtureFailures.Add([string]$entry.Value)
}
}
if ($fixtureFailures.Count -gt 0) {
return [pscustomobject]@{
Status = "fixture_error"
Failures = @($fixtureFailures)
}
}
$apiFailures = [System.Collections.Generic.List[string]]::new()
if (-not [bool]$Fixture.SameVolume) {
$apiFailures.Add("source, target, and backup are not on one volume")
}
if (-not [bool]$Fixture.NtfsVolume) {
$apiFailures.Add("attack fixture is not on NTFS")
}
if ($apiFailures.Count -gt 0) {
return [pscustomobject]@{
Status = "api_precondition_failure"
Failures = @($apiFailures)
}
}
return [pscustomobject]@{
Status = "ready"
Failures = @()
}
}
function Assert-MinerOpsNativeSwapFixtureReady {
param([Parameter(Mandatory = $true)]$Fixture)
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $Fixture
switch ([string]$classification.Status) {
"ready" { return }
"fixture_error" {
throw "Standard-user swap fixture is invalid: " +
"$(@($classification.Failures) -join '; ')."
}
"api_precondition_failure" {
throw "Standard-user swap has an API precondition failure: " +
"$(@($classification.Failures) -join '; ')."
}
default {
throw "Standard-user swap fixture classification failed closed."
}
}
}
function Assert-MinerOpsNativeSwapSecurityOutcome {
param(
[Parameter(Mandatory = $true)]$Result,
[Parameter(Mandatory = $true)]$Postconditions
)
if ([string]$Result.operation -cne "swap") {
throw "Swap result recorded the wrong operation."
}
$detail = "outer=$([string]$Result.outer_exception_type), " +
"outer_hresult=$([string]$Result.outer_hresult), " +
"leaf=$([string]$Result.leaf_exception_type), " +
"leaf_hresult=$([string]$Result.leaf_hresult), " +
"native=$([string]$Result.native_error), " +
"chain=$(@($Result.exception_chain_types) -join ' -> ')"
switch ([string]$Result.status) {
"succeeded" {
if (-not [bool]$Result.replace_invoked) {
throw "Successful swap result did not record File.Replace invocation."
}
throw "Standard user completed the protected replacement swap."
}
"security_denied" { }
"security_denied_requires_postconditions" { }
"fixture_error" {
throw "Standard-user swap fixture was invalid ($detail)."
}
"api_precondition_failure" {
throw "Standard-user swap hit an unrelated API precondition ($detail)."
}
default {
throw "Standard-user swap failed unexpectedly ($detail)."
}
}
if (-not [bool]$Result.replace_invoked) {
throw "Standard-user swap did not reach System.IO.File.Replace."
}
$required = [ordered]@{
TargetExists = "protected target is missing"
TargetIdentityUnchanged = "protected target identity changed"
TargetContentUnchanged = "protected target content changed"
TargetAclTrusted = "protected target ACL is not trusted"
TargetAclUnchanged = "protected target ACL changed"
TargetOwnerTrusted = "protected target owner is not trusted"
TargetOwnerUnchanged = "protected target owner changed"
TargetSingleLink = "protected target is not single-link"
SourceExists = "attacker source is missing"
SourceIdentityUnchanged = "attacker source identity changed"
SourceContentUnchanged = "attacker source content changed"
AttackerSourceNotPromoted = "attacker source was promoted to authority"
TargetDeniedStandardUserRead = `
"protected content became readable by the standard user"
TargetDeniedStandardUserModify = `
"protected target became modifiable by the standard user"
TargetDeniedStandardUserDelete = `
"protected target became deletable by the standard user"
TargetDeniedStandardUserRename = `
"protected target became renameable by the standard user"
NoAttackerReadableBackup = `
"replacement exposed protected content through a backup"
}
$failures = [System.Collections.Generic.List[string]]::new()
foreach ($entry in $required.GetEnumerator()) {
if (-not [bool]$Postconditions.($entry.Key)) {
$failures.Add([string]$entry.Value)
}
}
if ($failures.Count -gt 0) {
throw "Swap security postconditions failed ($detail): " +
"$($failures -join '; ')."
}
}
function Get-MinerOpsNativeFileIdentity {
param([Parameter(Mandatory = $true)]$Stream)
if (-not ("MinerOpsNativeHarnessFileIdentity" -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
public sealed class MinerOpsNativeHarnessFileIdentityResult
{
public string Identity { get; set; }
public uint VolumeSerialNumber { get; set; }
public ulong FileIndex { get; set; }
public uint NumberOfLinks { get; set; }
}
public static class MinerOpsNativeHarnessFileIdentity
{
[StructLayout(LayoutKind.Sequential)]
private struct ByHandleFileInformation
{
public uint FileAttributes;
public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
public uint VolumeSerialNumber;
public uint FileSizeHigh;
public uint FileSizeLow;
public uint NumberOfLinks;
public uint FileIndexHigh;
public uint FileIndexLow;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetFileInformationByHandle(
SafeFileHandle file, out ByHandleFileInformation information);
public static MinerOpsNativeHarnessFileIdentityResult Get(
SafeFileHandle file)
{
ByHandleFileInformation information;
if (!GetFileInformationByHandle(file, out information))
throw new Win32Exception(Marshal.GetLastWin32Error());
ulong fileIndex = ((ulong)information.FileIndexHigh << 32) |
information.FileIndexLow;
return new MinerOpsNativeHarnessFileIdentityResult {
Identity = String.Format(
"{0:X8}:{1:X8}{2:X8}",
information.VolumeSerialNumber,
information.FileIndexHigh,
information.FileIndexLow),
VolumeSerialNumber = information.VolumeSerialNumber,
FileIndex = fileIndex,
NumberOfLinks = information.NumberOfLinks
};
}
}
'@
}
return [MinerOpsNativeHarnessFileIdentity]::Get($Stream.SafeFileHandle)
}
@@ -0,0 +1,335 @@
$ErrorActionPreference = "Stop"
$root = Split-Path $PSScriptRoot -Parent
$scriptPath = Join-Path $root "Upgrade-MinerOpsAgentCredential.ps1"
. $scriptPath
$script:Passed = 0
$script:BootEventRecords = @()
$script:ToXmlCalls = 0
function Assert-True {
param(
[Parameter(Mandatory)][bool]$Condition,
[Parameter(Mandatory)][string]$Message
)
if (-not $Condition) { throw $Message }
}
function Assert-Throws {
param(
[Parameter(Mandatory)][scriptblock]$Action,
[string]$Pattern = ""
)
try { & $Action | Out-Null }
catch {
if ($Pattern -and $_.Exception.Message -notmatch $Pattern) {
throw "Expected error '$Pattern', got '$($_.Exception.Message)'."
}
return
}
throw "Expected action to fail."
}
function Invoke-ParserTest {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][scriptblock]$Action
)
& $Action
$script:Passed++
Write-Host "PASS: $Name"
}
function New-TestKernelBootXml {
param(
[string]$Namespace =
"http://schemas.microsoft.com/win/2004/08/events/event",
[string]$BootTypeContent = '<Data Name="BootType">0</Data>',
[switch]$OmitSystem,
[switch]$OmitEventData,
[switch]$DuplicateEventData
)
$system = if ($OmitSystem) { "" } else {
@"
<System>
<Provider Name="Microsoft-Windows-Kernel-Boot" />
<EventID>27</EventID>
<Version>1</Version>
<Level>4</Level>
<Task>33</Task>
<Opcode>0</Opcode>
<EventRecordID>42</EventRecordID>
<Channel>System</Channel>
</System>
"@
}
$eventData = if ($OmitEventData) { "" } else {
" <EventData>$BootTypeContent</EventData>"
}
if ($DuplicateEventData) {
$eventData += "`n <EventData>$BootTypeContent</EventData>"
}
return "<Event xmlns=`"$Namespace`">`n$system$eventData`n</Event>"
}
function New-TestEventRecord {
param(
[AllowNull()]$ProviderName = "Microsoft-Windows-Kernel-Boot",
[AllowNull()]$Id = [int]27,
[AllowNull()]$RecordId = [long]42,
[AllowNull()]$Version = [byte]1,
[AllowNull()]$Xml = (New-TestKernelBootXml)
)
$record = [pscustomobject]@{
ProviderName = $ProviderName
Id = $Id
RecordId = $RecordId
Version = $Version
XmlPayload = $Xml
}
$record | Add-Member -MemberType ScriptMethod -Name ToXml -Value {
$script:ToXmlCalls++
return $this.XmlPayload
}
return $record
}
function Get-WinEvent {
param($FilterHashtable, $ErrorAction)
return $script:BootEventRecords
}
Invoke-ParserTest "conventional namespaced Event XML and typed metadata parse" {
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord)
Assert-True ($parsed.ProviderName -ceq
"Microsoft-Windows-Kernel-Boot") "Typed provider was not retained."
Assert-True ($parsed.EventId -is [int] -and $parsed.EventId -eq 27) `
"Typed event ID was not retained."
Assert-True ($parsed.RecordId -is [long] -and $parsed.RecordId -eq 42) `
"Typed record ID was not retained."
Assert-True ($parsed.Version -is [byte] -and $parsed.Version -eq 1) `
"Typed event version was not retained."
Assert-True ($parsed.BootType -is [UInt32] -and $parsed.BootType -eq 0) `
"BootType payload was not parsed."
}
Invoke-ParserTest "supported event version zero parses" {
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -Version ([byte]0))
Assert-True ($parsed.Version -eq 0) "Version zero was not accepted."
}
Invoke-ParserTest "production Get-WinEvent path invokes ToXml parser" {
$script:ToXmlCalls = 0
$script:BootEventRecords = @(
New-TestEventRecord -RecordId ([long]43)
)
$identifier = Get-MinerOpsBootIdentifier
Assert-True ($identifier -ceq
"windows-full-boot-event-v1:000000000000002b") `
"Production event path returned the wrong identifier."
Assert-True ($script:ToXmlCalls -eq 1) `
"Production event path did not invoke ToXml exactly once."
}
Invoke-ParserTest "typed metadata is authoritative over XML scalar metadata" {
$xml = (New-TestKernelBootXml).Replace(
"<EventID>27</EventID>", "<EventID>999</EventID>"
).Replace(
"<EventRecordID>42</EventRecordID>",
"<EventRecordID>999</EventRecordID>"
)
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -Xml $xml)
Assert-True ($parsed.EventId -eq 27 -and $parsed.RecordId -eq 42) `
"XML scalar metadata overrode typed EventRecord properties."
}
Invoke-ParserTest "null typed RecordId fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -RecordId $null)
} "metadata"
}
Invoke-ParserTest "wrong typed provider fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -ProviderName "Unexpected-Provider")
} "metadata"
}
Invoke-ParserTest "wrong typed event ID fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -Id ([int]28))
} "metadata"
}
Invoke-ParserTest "wrong RecordId type fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -RecordId ([UInt64]42))
} "metadata"
}
Invoke-ParserTest "null event version fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -Version $null)
} "metadata"
}
Invoke-ParserTest "unsupported event version fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -Version ([byte]2))
} "unsupported"
}
Invoke-ParserTest "unexpected Event XML namespace fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml `
-Namespace "urn:unexpected"))
} "root"
}
Invoke-ParserTest "malformed Event XML fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -Xml "<Event>")
} "malformed"
}
Invoke-ParserTest "missing System element fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml -OmitSystem))
} "schema"
}
Invoke-ParserTest "missing EventData fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml -OmitEventData))
} "schema"
}
Invoke-ParserTest "duplicated EventData fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml `
(New-TestKernelBootXml -DuplicateEventData))
} "schema"
}
Invoke-ParserTest "missing BootType fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml `
-BootTypeContent '<Data Name="LoadOptions"></Data>'))
} "schema"
}
Invoke-ParserTest "duplicate BootType fails closed" {
$payload = '<Data Name="BootType">0</Data>' +
'<Data Name="BootType">0</Data>'
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml `
-BootTypeContent $payload))
} "ambiguous"
}
Invoke-ParserTest "nonnumeric BootType fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml `
-BootTypeContent '<Data Name="BootType">full</Data>'))
} "canonical"
}
Invoke-ParserTest "noncanonical BootType fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml `
-BootTypeContent '<Data Name="BootType">00</Data>'))
} "canonical"
}
Invoke-ParserTest "structured BootType content fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml `
-BootTypeContent `
'<Data Name="BootType"><Value>0</Value></Data>'))
} "schema"
}
Invoke-ParserTest "BootType outside UInt32 fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml `
-BootTypeContent `
'<Data Name="BootType">4294967296</Data>'))
} "invalid"
}
Invoke-ParserTest "nonzero BootType is not a full-boot anchor" {
$script:BootEventRecords = @(
New-TestEventRecord -Xml (New-TestKernelBootXml `
-BootTypeContent '<Data Name="BootType">1</Data>')
)
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
}
Invoke-ParserTest "empty event query fails closed" {
$script:BootEventRecords = @()
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
}
Invoke-ParserTest "duplicate qualifying record IDs fail closed" {
$script:BootEventRecords = @(
New-TestEventRecord -RecordId ([long]42)
New-TestEventRecord -RecordId ([long]43)
New-TestEventRecord -RecordId ([long]42)
)
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
}
Invoke-ParserTest "equal stored and current anchors deny reset" {
Assert-Throws {
Assert-MinerOpsBootEpochChanged `
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002a"
} "without a verified full Restart"
}
Invoke-ParserTest "strictly greater current anchor passes ordering" {
Assert-MinerOpsBootEpochChanged `
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b"
}
Invoke-ParserTest "smaller current anchor fails closed" {
Assert-Throws {
Assert-MinerOpsBootEpochChanged `
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
-CurrentBootIdentifier "windows-full-boot-event-v1:0000000000000029"
} "older than the"
}
Invoke-ParserTest "malformed stored anchor fails closed" {
Assert-Throws {
Assert-MinerOpsBootEpochChanged `
-StoredBootIdentifier "windows-full-boot-event-v1:not-a-record" `
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b"
} "canonical"
}
Write-Host "$script:Passed boot-event parser tests passed."
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -32,6 +32,9 @@ function Assert-SourceContains {
Assert-SourceContains 'enrollment\.json' `
"Bootstrap enrollment file is not explicit."
Assert-SourceContains 'workflow\s*-ne\s*"new_agent"' `
"New-Agent bootstrap does not reject transition packages."
Assert-SourceContains '/api/agent/enroll' `
"Bootstrap does not use the secure enrollment endpoint."
@@ -44,8 +47,20 @@ Assert-SourceContains 'agent-config\.json\.pending' `
Assert-SourceContains 'agent-config\.recovery\.json' `
"Post-redemption credential recovery path is missing."
Assert-SourceContains 'Protect-MinerOpsCredentialFile' `
"Credential file ACL protection is missing."
Assert-SourceContains 'Open-MinerOpsEnrollmentCredentialFileCreateNew' `
"Handle-based credential creation is missing."
Assert-SourceContains 'FileMode\]::CreateNew' `
"Credential files are not created with fail-if-present semantics."
Assert-SourceContains 'FileShare\]::None' `
"Credential files are not held exclusively while being created."
Assert-SourceContains 'Flush\(\$true\)' `
"Credential writes are not durably flushed through the trusted handle."
Assert-SourceContains 'GetNumberOfLinks' `
"Enrollment does not reject hard-linked credential targets."
Assert-SourceContains 'S-1-5-18' `
"SYSTEM ACL entry is missing."
@@ -53,12 +68,73 @@ Assert-SourceContains 'S-1-5-18' `
Assert-SourceContains 'S-1-5-32-544' `
"Administrators ACL entry is missing."
Assert-SourceContains 'WindowsIdentity\]::GetCurrent\(\)\.User' `
"Installing user ACL entry is missing."
Assert-SourceContains 'SetOwner\(\$administrators\)' `
"Stable Administrators ownership is missing from credential ACLs."
Assert-SourceContains 'Move-Item[\s\S]*\$ConfigPath' `
Assert-SourceContains '\[IO\.File\]::Move\(\$PendingConfigPath, \$ConfigPath\)' `
"Atomic final config move is missing."
Assert-SourceContains 'Assert-MinerOpsEnrollmentDirectory' `
"Enrollment does not revalidate the directory trust boundary."
Assert-SourceContains 'minerops-filesystem-trust\.blocked-' `
"Enrollment directory repair lacks an external durable trust blocker."
Assert-SourceContains 'ApproveFilesystemTrustResetAfterReboot' `
"Enrollment lacks an explicit post-reboot trust-reset gate."
Assert-SourceContains 'windows-full-boot-event-v1:' `
"Enrollment blockers do not use canonical full-kernel-boot records."
if ($source -match 'LastBootUpTime') {
throw "Enrollment must not use a wall-clock boot timestamp."
}
if ($source -match 'Remove-MinerOpsEnrollmentTrustBlocker') {
throw "Enrollment must not have independent trust-blocker deletion authority."
}
Assert-SourceContains 'Enrollment cannot clear an existing filesystem trust blocker' `
"Enrollment does not fail closed on retained filesystem trust blockers."
Assert-SourceContains 'Assert-MinerOpsEnrollmentTrustedAncestors' `
"Enrollment does not validate ancestor namespace protection."
foreach ($functionName in @(
"Assert-MinerOpsEnrollmentNoReparsePath",
"Assert-MinerOpsEnrollmentTrustedAncestors"
)) {
$functionSource = [regex]::Match(
$source,
"(?s)function $functionName \{.*?\n\}"
).Value
if ([string]::IsNullOrWhiteSpace($functionSource)) {
throw "$functionName source was not found."
}
if ($functionSource -notmatch '\$current\s*=\s*\$root') {
throw "$functionName does not preserve the filesystem root."
}
if ($functionSource -notmatch '\$fullPath\.Equals\(\s*\$root') {
throw "$functionName does not protect a root-valued full path from trimming."
}
if ($functionSource -match '\$root\.TrimEnd\(') {
throw "$functionName converts a root-qualified path to drive-relative form."
}
if ($functionSource -match 'GetFullPath\(\$Path\)\.TrimEnd\(') {
throw "$functionName trims the filesystem root before identifying it."
}
if ($functionName -eq "Assert-MinerOpsEnrollmentTrustedAncestors" -and
$functionSource -notmatch '\$parent\.Equals\(\s*\$root') {
throw "$functionName does not protect a root-valued parent from trimming."
}
}
Assert-SourceContains 'Open-MinerOpsEnrollmentDirectoryLock' `
"Enrollment does not lock an insecure target during repair."
Assert-SourceContains 'DirectoryInfo\(\$staging\)' `
"Enrollment does not securely stage a new directory."
Assert-SourceContains '& \$Installer' `
"Bootstrap does not hand off to the safe installer."
@@ -68,6 +144,78 @@ Assert-SourceContains 'Start-ScheduledTask -TaskName "MinerOpsAgent"' `
Assert-SourceContains 'Remove-Item -LiteralPath \$EnrollmentPath' `
"Consumed extracted enrollment token is not removed."
if ($source -match '\[IO\.File\]::WriteAllText\(\s*\$(Pending|Recovery)ConfigPath') {
throw "Enrollment must not truncate or adopt a credential pathname."
}
$createFunction = [regex]::Match(
$source,
'(?s)function Open-MinerOpsEnrollmentCredentialFileCreateNew \{.*?\n\}'
).Value
$createIndex = $createFunction.IndexOf(
'New-MinerOpsEnrollmentProtectedFileCreateNewStream'
)
$verifyIndex = $createFunction.IndexOf('Test-MinerOpsEnrollmentCredentialAcl')
$linkIndex = $createFunction.IndexOf('Assert-MinerOpsEnrollmentSingleLinkStream')
if (
$createIndex -lt 0 -or $verifyIndex -le $createIndex -or
$linkIndex -le $verifyIndex -or
$createFunction -match 'Set-MinerOpsEnrollmentStreamAcl'
) {
throw "Enrollment credential creation must apply FileSecurity at CreateNew, verify it, then reject links before content."
}
$createStreamFunction = [regex]::Match(
$source,
'(?s)function New-MinerOpsEnrollmentProtectedFileCreateNewStream \{.*?\n\}'
).Value
foreach ($requiredCreateContract in @(
'\[IO\.FileStream\]::new',
'FileMode\]::CreateNew',
'FileSystemRights\]::WriteData',
'FileSystemRights\]::ReadPermissions',
'FileSystemRights\]::Synchronize',
'FileShare\]::None',
'\$Acl'
)) {
if ($createStreamFunction -notmatch $requiredCreateContract) {
throw "Enrollment protected CreateNew stream contract is incomplete."
}
}
if ($createStreamFunction -match 'FullControl|SetAccessControl|Set-Acl') {
throw "Enrollment protected creation requests excess rights or repairs by path."
}
$repairFunction = [regex]::Match(
$source,
'(?s)function Protect-MinerOpsEnrollmentExistingConfig \{.*?\n\}'
).Value
foreach ($requiredRepairRight in @(
'ReadPermissions',
'ChangePermissions',
'TakeOwnership',
'Synchronize'
)) {
if ($repairFunction -notmatch $requiredRepairRight) {
throw "Enrollment existing-config repair is missing $requiredRepairRight."
}
}
if ($repairFunction -match 'Set-Acl|FileAccess') {
throw "Enrollment existing-config repair must remain handle-bound."
}
$writeFunction = [regex]::Match(
$source,
'(?s)function Write-MinerOpsEnrollmentCredentialJson \{.*?\n\}'
).Value
if (
$writeFunction -notmatch 'Assert-MinerOpsEnrollmentSingleLinkStream' -or
$writeFunction -notmatch '\$Stream\.Write' -or
$writeFunction -notmatch '\$Stream\.Flush\(\$true\)'
) {
throw "Enrollment credential content is not written and flushed through the verified handle."
}
$existingConfigGuard = $source.IndexOf(
'Test-Path -LiteralPath $ConfigPath'
)
@@ -85,7 +233,7 @@ if (
}
$configMove = $source.IndexOf(
'-Destination $ConfigPath'
'[IO.File]::Move($PendingConfigPath, $ConfigPath)'
)
$installerCall = $source.IndexOf(
+131 -1
View File
@@ -33,7 +33,7 @@ if ($preflight -lt 0 -or $preflight -gt $firstTargetChange) {
throw "Source preflight must happen before target changes."
}
Assert-SourceContains 'if \(\$null -ne \(Get-MinerOpsScheduledTask' `
Assert-SourceContains '\$existingTask\s*=\s*Get-MinerOpsScheduledTask' `
"Existing task preservation guard is missing."
Assert-SourceContains 'Preserving existing Scheduled Task' `
"Existing task preservation is not explicit."
@@ -49,6 +49,136 @@ Assert-SourceContains 'Unregister-ScheduledTask' `
"Rollback does not remove tasks created by a failed install."
Assert-SourceContains 'could not restart restored MinerOpsAgent' `
"Rollback restart failure is not surfaced."
Assert-SourceContains 'Assert-MinerOpsInstallerTrustedAncestors' `
"Installer does not validate ancestor namespace protection."
foreach ($functionName in @(
"Assert-MinerOpsInstallerNoReparsePath",
"Assert-MinerOpsInstallerTrustedAncestors"
)) {
$functionSource = [regex]::Match(
$source,
"(?s)function $functionName \{.*?\n\}"
).Value
if ([string]::IsNullOrWhiteSpace($functionSource)) {
throw "$functionName source was not found."
}
if ($functionSource -notmatch '\$current\s*=\s*\$root') {
throw "$functionName does not preserve the filesystem root."
}
if ($functionSource -notmatch '\$fullPath\.Equals\(\s*\$root') {
throw "$functionName does not protect a root-valued full path from trimming."
}
if ($functionSource -match '\$root\.TrimEnd\(') {
throw "$functionName converts a root-qualified path to drive-relative form."
}
if ($functionSource -match 'GetFullPath\(\$Path\)\.TrimEnd\(') {
throw "$functionName trims the filesystem root before identifying it."
}
if ($functionName -eq "Assert-MinerOpsInstallerTrustedAncestors" -and
$functionSource -notmatch '\$parent\.Equals\(\s*\$root') {
throw "$functionName does not protect a root-valued parent from trimming."
}
}
Assert-SourceContains 'Open-MinerOpsInstallerDirectoryLock' `
"Installer does not lock an insecure target against replacement."
Assert-SourceContains 'minerops-filesystem-trust\.blocked-' `
"Installer directory repair lacks an external durable provenance blocker."
Assert-SourceContains 'ApproveFilesystemTrustResetAfterReboot' `
"Installer lacks an explicit post-reboot trust-reset gate."
Assert-SourceContains 'DirectoryInfo\(\$staging\)' `
"Installer does not stage a protected new directory before publication."
Assert-SourceContains 'Assert-MinerOpsInstallerDirectory' `
"Installer does not read back the final directory security invariant."
Assert-SourceContains 'Protect-MinerOpsInstallerExistingConfig' `
"Installer does not contain the existing active-config ACL."
Assert-SourceContains 'GetNumberOfLinks' `
"Installer does not reject hard-linked runtime, config, or backup files."
Assert-SourceContains 'Assert-MinerOpsInstallerTrustedRuntimeFile' `
"Installer does not verify existing runtime rollback provenance."
Assert-SourceContains 'Protect-MinerOpsInstallerRuntimeFile' `
"Installer does not secure and verify published runtime and rollback copies."
Assert-SourceContains 'Pre-reset runtime is evidence, never rollback authority' `
"Installer trust reset can adopt pre-reset runtime as rollback authority."
Assert-SourceContains 'parent-owned filesystem trust blocker remains' `
"Installer does not explicitly retain the parent-owned trust blocker."
Assert-SourceContains 'windows-full-boot-event-v1:' `
"Installer blockers do not use canonical full-kernel-boot records."
if ($source -match 'Remove-MinerOpsInstallerTrustBlocker|Remove-Item[^\r\n]*TrustBlocker') {
throw "Installer must have no filesystem trust-blocker deletion path."
}
if ($source -match 'LastBootUpTime') {
throw "Installer must not create wall-clock boot-epoch records."
}
$createStreamFunction = [regex]::Match(
$source,
'(?s)function New-MinerOpsInstallerProtectedFileCreateNewStream \{.*?\n\}'
).Value
foreach ($requiredCreateContract in @(
'\[IO\.FileStream\]::new',
'FileMode\]::CreateNew',
'FileSystemRights\]::WriteData',
'FileSystemRights\]::ReadPermissions',
'FileSystemRights\]::Synchronize',
'FileShare\]::None',
'\$Acl'
)) {
if ($createStreamFunction -notmatch $requiredCreateContract) {
throw "Installer protected CreateNew stream contract is incomplete."
}
}
if ($createStreamFunction -match 'FullControl|SetAccessControl|Set-Acl') {
throw "Installer protected creation requests excess rights or repairs by path."
}
$trustBlockerFunction = [regex]::Match(
$source,
'(?s)function New-MinerOpsInstallerTrustBlocker \{.*?\n\}'
).Value
$trustCreateIndex = $trustBlockerFunction.IndexOf(
'New-MinerOpsInstallerProtectedFileCreateNewStream'
)
$trustVerifyIndex = $trustBlockerFunction.IndexOf(
'Test-MinerOpsInstallerProtectedFileAcl'
)
$trustContentIndex = $trustBlockerFunction.IndexOf('$record =')
if (
$trustCreateIndex -lt 0 -or
$trustVerifyIndex -le $trustCreateIndex -or
$trustContentIndex -le $trustVerifyIndex
) {
throw "Installer trust blockers must be protected and verified before content."
}
$configRepairFunction = [regex]::Match(
$source,
'(?s)function Protect-MinerOpsInstallerExistingConfig \{.*?\n\}'
).Value
foreach ($requiredRepairRight in @(
'ReadPermissions',
'ChangePermissions',
'TakeOwnership',
'Synchronize'
)) {
if ($configRepairFunction -notmatch $requiredRepairRight) {
throw "Installer existing-config repair is missing $requiredRepairRight."
}
}
if ($configRepairFunction -match 'Set-Acl|FileAccess') {
throw "Installer existing-config repair must remain handle-bound."
}
$repairFunction = [regex]::Match(
$source,
'(?s)function Initialize-MinerOpsInstallerDirectory \{.*?\n\}'
).Value
$repairIndex = $repairFunction.IndexOf('Set-Acl -LiteralPath $fullPath')
$abortIndex = $repairFunction.IndexOf('this invocation will not back up')
if ($repairIndex -lt 0 -or $abortIndex -le $repairIndex) {
throw "Insecure existing-directory containment must abort before publication."
}
if ($source -match 'Register-ScheduledTask[^\r\n]*-Force') {
throw "Installer must not force-overwrite Scheduled Tasks."
@@ -0,0 +1,582 @@
$ErrorActionPreference = "Stop"
$supportPath = Join-Path $PSScriptRoot "MinerOpsNativeHarnessSupport.ps1"
. $supportPath
function Assert-True {
param(
[Parameter(Mandatory = $true)][bool]$Condition,
[Parameter(Mandatory = $true)][string]$Message
)
if (-not $Condition) { throw $Message }
}
function Assert-Throws {
param(
[Parameter(Mandatory = $true)][scriptblock]$Action,
[Parameter(Mandatory = $true)][string]$Pattern
)
try {
& $Action | Out-Null
}
catch {
if ($_.Exception.Message -notmatch $Pattern) {
throw "Expected '$Pattern', got '$($_.Exception.Message)'."
}
return
}
throw "Expected action to fail with '$Pattern'."
}
$script:Passed = 0
function Invoke-HarnessTest {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][scriptblock]$Action
)
& $Action
$script:Passed++
Write-Host "PASS: $Name"
}
function New-PassingSwapPostconditions {
return [pscustomobject]@{
TargetExists = $true
TargetIdentityUnchanged = $true
TargetContentUnchanged = $true
TargetAclTrusted = $true
TargetAclUnchanged = $true
TargetOwnerTrusted = $true
TargetOwnerUnchanged = $true
TargetSingleLink = $true
SourceExists = $true
SourceIdentityUnchanged = $true
SourceContentUnchanged = $true
AttackerSourceNotPromoted = $true
TargetDeniedStandardUserRead = $true
TargetDeniedStandardUserModify = $true
TargetDeniedStandardUserDelete = $true
TargetDeniedStandardUserRename = $true
NoAttackerReadableBackup = $true
}
}
function New-ReadySwapFixture {
return [pscustomobject]@{
SourceExists = $true
TargetExists = $true
PathsDistinct = $true
SourceCreatedByStandardUser = $true
SourceOwnedByStandardUser = $true
SourceReadable = $true
SourceModifiable = $true
SourceRenameable = $true
SourceDeleteCapable = $true
SourceReplaceAccessVerified = $true
TargetAclTrusted = $true
TargetOwnerTrusted = $true
TargetIdentityCaptured = $true
SourceIdentityCaptured = $true
FileIdentitiesDistinct = $true
BackupArgumentValid = $true
BackupPathsDistinct = $true
BackupExpectedStateVerified = $true
BackupConfidentialitySafe = $true
SameVolume = $true
NtfsVolume = $true
}
}
function New-SwapResult {
param([Parameter(Mandatory = $true)][string]$Status)
return [pscustomobject]@{
operation = "swap"
status = $Status
exception_type = ""
hresult = 0
outer_exception_type = ""
outer_hresult = 0
leaf_exception_type = ""
leaf_hresult = 0
native_error = $null
exception_chain_types = @()
replace_invoked = $true
}
}
Invoke-HarnessTest "helper classifies success and status exit codes" {
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "succeeded") -eq 0) `
"Success exit code changed."
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "security_denied") -eq 5) `
"Security-denial exit code changed."
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "fixture_error") -eq 6) `
"Fixture-error exit code changed."
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "unexpected_failure") -eq 7) `
"Unexpected-failure exit code changed."
Assert-True ((Get-MinerOpsNativeStatusExitCode `
-Status "security_denied_requires_postconditions") -eq 8) `
"Postcondition-gated security exit code changed."
Assert-True ((Get-MinerOpsNativeStatusExitCode `
-Status "api_precondition_failure") -eq 9) `
"API-precondition exit code changed."
}
Invoke-HarnessTest "helper distinguishes security, fixture, and unexpected failures" {
$denied = [UnauthorizedAccessException]::new("denied")
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $denied) -ceq "security_denied") `
"UnauthorizedAccessException was not a security denial."
$unableToRemove = [IO.IOException]::new("expected", -2147023721)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $unableToRemove) -ceq
"security_denied_requires_postconditions") `
"ERROR_UNABLE_TO_REMOVE_REPLACED was not postcondition-gated."
$missing = [IO.FileNotFoundException]::new("missing")
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $missing) -ceq "fixture_error") `
"Missing swap source was not a fixture error."
$invalidArgument = [ArgumentException]::new("illegal backup path")
Assert-True ($invalidArgument.HResult -eq -2147024809) `
"ArgumentException no longer carries 0x80070057."
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $invalidArgument) -ceq "fixture_error") `
"ERROR_INVALID_PARAMETER-like argument failure was not a fixture error."
$crossVolume = [IO.IOException]::new("different volume", -2147024879)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $crossVolume) -ceq
"api_precondition_failure") `
"Cross-volume swap was not an API-precondition failure."
$partialMove = [IO.IOException]::new("unexpected", -2147023720)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $partialMove) -ceq "unexpected_failure") `
"Partial ReplaceFile failure was accepted."
}
Invoke-HarnessTest "helper unwraps MethodInvocationException security denial" {
$denied = [UnauthorizedAccessException]::new("denied")
$wrapped = [Management.Automation.MethodInvocationException]::new(
"method invocation failed",
$denied
)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $wrapped) -ceq "security_denied") `
"Wrapped UnauthorizedAccessException was not a security denial."
}
Invoke-HarnessTest "helper unwraps multiple PowerShell exception layers" {
$denied = [UnauthorizedAccessException]::new("denied")
$methodWrapper = [Management.Automation.MethodInvocationException]::new(
"method invocation failed",
$denied
)
$runtimeWrapper = [Management.Automation.RuntimeException]::new(
"runtime failed",
$methodWrapper
)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $runtimeWrapper) -ceq "security_denied") `
"Nested PowerShell wrappers were not unwrapped."
}
Invoke-HarnessTest "helper decodes only HRESULT_FROM_WIN32 values" {
Assert-True ((Get-MinerOpsNativeWin32Error -HResult -2147024891) -eq 5) `
"0x80070005 did not decode as ERROR_ACCESS_DENIED."
$accessDenied = [IO.IOException]::new("access denied", -2147024891)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $accessDenied) -ceq "security_denied") `
"Win32-derived ERROR_ACCESS_DENIED was not security evidence."
Assert-True ($null -eq (
Get-MinerOpsNativeWin32Error -HResult -2146233087
)) "CLR HRESULT 0x80131501 produced a bogus native error."
}
Invoke-HarnessTest "unrelated wrapped IOException is not a security denial" {
$ioFailure = [IO.IOException]::new("unrelated")
$wrapped = [Management.Automation.MethodInvocationException]::new(
"method invocation failed",
$ioFailure
)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $wrapped) -ceq "unexpected_failure") `
"Unrelated wrapped IOException was accepted as security evidence."
}
Invoke-HarnessTest "wrapped fixture and precondition failures stay distinct" {
$missing = [IO.FileNotFoundException]::new("missing")
$wrappedMissing = [Management.Automation.MethodInvocationException]::new(
"method invocation failed",
$missing
)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $wrappedMissing) -ceq "fixture_error") `
"Wrapped fixture error changed classification."
$crossVolume = [IO.IOException]::new("different volume", -2147024879)
$wrappedCrossVolume = [Reflection.TargetInvocationException]::new(
"target invocation failed",
$crossVolume
)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $wrappedCrossVolume) -ceq
"api_precondition_failure") `
"Wrapped API precondition changed classification."
}
Invoke-HarnessTest "exception diagnostics report outer leaf and chain types" {
$denied = [UnauthorizedAccessException]::new("denied")
$wrapped = [Management.Automation.MethodInvocationException]::new(
"method invocation failed",
$denied
)
$details = Get-MinerOpsNativeExceptionDetails -Exception $wrapped
Assert-True ($details.OuterExceptionType -ceq
"System.Management.Automation.MethodInvocationException") `
"Exception diagnostics recorded the wrong outer type."
Assert-True ($details.LeafExceptionType -ceq
"System.UnauthorizedAccessException") `
"Exception diagnostics recorded the wrong leaf type."
Assert-True ($details.ExceptionChainTypes.Count -eq 2) `
"Exception diagnostics recorded the wrong chain length."
Assert-True (-not $details.ExceptionChainTruncated) `
"A short exception chain was reported as truncated."
}
Invoke-HarnessTest "exception diagnostics handle null InnerException" {
$wrapperOnly = [Management.Automation.RuntimeException]::new(
"wrapper without inner exception"
)
$details = Get-MinerOpsNativeExceptionDetails -Exception $wrapperOnly
Assert-True ($details.OuterExceptionType -ceq $details.LeafExceptionType) `
"Null InnerException did not leave the wrapper as the leaf."
Assert-True ($details.ExceptionChainTypes.Count -eq 1) `
"Null InnerException produced an invalid chain."
}
Invoke-HarnessTest "exception-chain traversal is bounded defensively" {
[Exception]$deep = [UnauthorizedAccessException]::new("denied")
foreach ($level in 1..40) {
$deep = [Exception]::new("wrapper $level", $deep)
}
$details = Get-MinerOpsNativeExceptionDetails -Exception $deep
Assert-True ($details.ExceptionChainTypes.Count -eq 32) `
"Deep exception traversal did not stop at its bound."
Assert-True ([bool]$details.ExceptionChainTruncated) `
"Deep exception traversal did not report truncation."
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $deep) -ceq "unexpected_failure") `
"Truncated exception evidence did not fail closed."
}
Invoke-HarnessTest "swap probe parses and passes an explicit native null" {
$probePath = Join-Path $PSScriptRoot `
"Invoke-MinerOpsStandardUserProbe.ps1"
$tokens = $null
$parseErrors = $null
[Management.Automation.Language.Parser]::ParseFile(
$probePath,
[ref]$tokens,
[ref]$parseErrors
) | Out-Null
Assert-True ($parseErrors.Count -eq 0) `
"Standard-user probe has parser errors."
$probeSource = [IO.File]::ReadAllText($probePath)
Assert-True ($probeSource -match '\[NullString\]::Value') `
"Swap probe does not pass an explicit native null backup argument."
Assert-True ($probeSource -notmatch
'File\]::Replace\([^\)]*\$null') `
"Swap probe regressed to a bare PowerShell null backup argument."
Assert-True ($probeSource -match
'backup-producing swap fixtures are prohibited') `
"Swap probe no longer prohibits confidentiality-unsafe backups."
}
Invoke-HarnessTest "NullString preserves native null for a .NET string" {
if (-not ("MinerOpsNativeHarnessNullStringProbe" -as [type])) {
Add-Type -TypeDefinition @'
public static class MinerOpsNativeHarnessNullStringProbe
{
public static bool IsNull(string value)
{
return value == null;
}
}
'@
}
Assert-True (-not (
[MinerOpsNativeHarnessNullStringProbe]::IsNull($null)
)) "A bare PowerShell null was not coerced to String.Empty."
Assert-True (
[MinerOpsNativeHarnessNullStringProbe]::IsNull([NullString]::Value)
) "NullString did not preserve native null for a .NET string."
}
Invoke-HarnessTest "same source and destination is a fixture failure" {
$fixture = New-ReadySwapFixture
$fixture.PathsDistinct = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "fixture_error") `
"Equal source and destination paths were accepted."
}
Invoke-HarnessTest "missing source is a fixture failure" {
$fixture = New-ReadySwapFixture
$fixture.SourceExists = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "fixture_error") `
"Missing source was accepted."
}
Invoke-HarnessTest "missing target is a fixture failure" {
$fixture = New-ReadySwapFixture
$fixture.TargetExists = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "fixture_error") `
"Missing target was accepted."
}
Invoke-HarnessTest "invalid backup argument is a fixture failure" {
foreach ($property in @(
"BackupArgumentValid",
"BackupPathsDistinct",
"BackupExpectedStateVerified",
"BackupConfidentialitySafe"
)) {
$fixture = New-ReadySwapFixture
$fixture.$property = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "fixture_error") `
"Invalid backup property $property was accepted."
}
}
Invoke-HarnessTest "cross-volume limitation is not a security pass" {
$fixture = New-ReadySwapFixture
$fixture.SameVolume = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "api_precondition_failure") `
"Cross-volume fixture was not an API-precondition failure."
}
Invoke-HarnessTest "valid controlled same-volume source is attack-ready" {
$classification = Get-MinerOpsNativeSwapFixtureStatus `
-Fixture (New-ReadySwapFixture)
Assert-True ($classification.Status -ceq "ready") `
"Valid same-volume fixture was not attack-ready."
}
Invoke-HarnessTest "source ownership and control are mandatory" {
foreach ($property in @(
"SourceCreatedByStandardUser",
"SourceOwnedByStandardUser",
"SourceReadable",
"SourceModifiable",
"SourceRenameable",
"SourceDeleteCapable",
"SourceReplaceAccessVerified"
)) {
$fixture = New-ReadySwapFixture
$fixture.$property = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "fixture_error") `
"Missing source-control proof $property was accepted."
}
}
Invoke-HarnessTest "invalid swap fixture cannot pass as security" {
Assert-Throws -Pattern "fixture was invalid" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "fixture_error") `
-Postconditions (New-PassingSwapPostconditions)
}
}
Invoke-HarnessTest "unrelated swap API precondition cannot pass as security" {
Assert-Throws -Pattern "unrelated API precondition" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "api_precondition_failure") `
-Postconditions (New-PassingSwapPostconditions)
}
}
Invoke-HarnessTest "successful replacement is a security failure" {
Assert-Throws -Pattern "completed the protected replacement" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "succeeded") `
-Postconditions (New-PassingSwapPostconditions)
}
}
Invoke-HarnessTest "security denial passes only with all postconditions" {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions (New-PassingSwapPostconditions)
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult `
-Status "security_denied_requires_postconditions") `
-Postconditions (New-PassingSwapPostconditions)
$failed = New-PassingSwapPostconditions
$failed.TargetAclTrusted = $false
Assert-Throws -Pattern "target ACL is not trusted" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions $failed
}
}
Invoke-HarnessTest "denial with changed target identity is a security failure" {
$failed = New-PassingSwapPostconditions
$failed.TargetIdentityUnchanged = $false
Assert-Throws -Pattern "target identity changed" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions $failed
}
}
Invoke-HarnessTest "denial with changed target content is a security failure" {
$failed = New-PassingSwapPostconditions
$failed.TargetContentUnchanged = $false
Assert-Throws -Pattern "target content changed" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions $failed
}
}
Invoke-HarnessTest "denial with changed ACL or owner is a security failure" {
foreach ($property in @(
"TargetAclTrusted",
"TargetAclUnchanged",
"TargetOwnerTrusted",
"TargetOwnerUnchanged"
)) {
$failed = New-PassingSwapPostconditions
$failed.$property = $false
Assert-Throws -Pattern "target (ACL|owner)" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions $failed
}
}
}
Invoke-HarnessTest "attacker-readable protected backup is a security failure" {
$failed = New-PassingSwapPostconditions
$failed.NoAttackerReadableBackup = $false
Assert-Throws -Pattern "through a backup" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions $failed
}
}
Invoke-HarnessTest "denial must record that File.Replace was reached" {
$result = New-SwapResult -Status "security_denied"
$result.replace_invoked = $false
Assert-Throws -Pattern "did not reach System.IO.File.Replace" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result $result -Postconditions (New-PassingSwapPostconditions)
}
}
$initialState = Get-MinerOpsNativeGetItemState
try {
Invoke-HarnessTest "trace leaves no global function when none existed" {
$childScript = Join-Path $PSScriptRoot `
"Test-MinerOpsNativeHarnessGetItemState.ps1"
$powerShell = (Get-Process -Id $PID).Path
& $powerShell -NoLogo -NoProfile -File $childScript `
-Mode NoExistingFunction -SupportPath $supportPath
Assert-True ($LASTEXITCODE -eq 0) `
"No-existing-function child test failed."
}
Invoke-HarnessTest "trace restores an existing global function exactly" {
$childScript = Join-Path $PSScriptRoot `
"Test-MinerOpsNativeHarnessGetItemState.ps1"
$powerShell = (Get-Process -Id $PID).Path
& $powerShell -NoLogo -NoProfile -File $childScript `
-Mode ExistingFunction -SupportPath $supportPath
Assert-True ($LASTEXITCODE -eq 0) `
"Existing-function child test failed."
}
Invoke-HarnessTest "trace cleanup survives Action failure" {
$before = Get-MinerOpsNativeGetItemState
Assert-Throws -Pattern "injected trace failure" -Action {
Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $supportPath | Out-Null
throw "injected trace failure"
}
}
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "throwing trace action"
}
Invoke-HarnessTest "trace cleanup survives a later assertion failure" {
$before = Get-MinerOpsNativeGetItemState
try {
[void](Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $supportPath
})
throw "injected post-trace assertion failure"
}
catch {
Assert-True (
$_.Exception.Message -match "post-trace assertion failure"
) "Unexpected post-trace failure."
}
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "post-trace assertion failure"
}
Invoke-HarnessTest "multiple sequential traces preserve command state" {
$before = Get-MinerOpsNativeGetItemState
foreach ($iteration in 1..3) {
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $supportPath
})
Assert-True ($observed.Count -eq 1) `
"Sequential trace $iteration recorded the wrong count."
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "sequential trace $iteration"
}
}
Invoke-HarnessTest "simulated top-level failure restores Get-Item" {
$before = Get-MinerOpsNativeGetItemState
try {
try {
[void](Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $supportPath
})
throw "injected top-level harness failure"
}
finally {
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "simulated harness final cleanup"
}
}
catch {
Assert-True (
$_.Exception.Message -match "top-level harness failure"
) "Unexpected simulated top-level failure."
}
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "after simulated top-level failure"
}
}
finally {
Assert-MinerOpsNativeGetItemState -Expected $initialState `
-Context "portable native-harness tests final cleanup"
}
Write-Host "$Passed native-harness regression tests passed."
@@ -0,0 +1,48 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet("NoExistingFunction", "ExistingFunction")]
[string]$Mode,
[Parameter(Mandatory = $true)][string]$SupportPath
)
$ErrorActionPreference = "Stop"
. $SupportPath
if ($Mode -ceq "NoExistingFunction") {
$before = Get-MinerOpsNativeGetItemState
if ([bool]$before.FunctionExisted) {
throw "Child process unexpectedly began with a global Get-Item function."
}
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $SupportPath
})
if ($observed -cnotcontains $SupportPath) {
throw "Trace did not observe Get-Item."
}
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "no-preexisting-function child test"
return
}
$existing = { param($LiteralPath) "preexisting:$LiteralPath" }
Set-Item -LiteralPath "Function:\global:Get-Item" -Value $existing -Force
$before = Get-MinerOpsNativeGetItemState
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $SupportPath
})
if ($observed -cnotcontains $SupportPath) {
throw "Trace did not observe Get-Item over an existing global function."
}
$after = Get-MinerOpsNativeGetItemState
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "preexisting-function child test"
if ($after.FunctionScriptBlock.ToString() -cne $existing.ToString()) {
throw "Existing Get-Item ScriptBlock was not restored exactly."
}
if (-not [object]::ReferenceEquals(
$after.FunctionScriptBlock,
$before.FunctionScriptBlock
)) {
throw "Existing Get-Item ScriptBlock instance changed."
}
@@ -0,0 +1,305 @@
# 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.
+45
View File
@@ -148,6 +148,7 @@ def download_windows_agent_package(
package = build_windows_agent_package(
enrollment_token=package_request.token,
hub_url=hub_url,
workflow="new_agent",
)
except AgentWindowsPackageSourceError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@@ -166,6 +167,50 @@ def download_windows_agent_package(
},
)
@router.post("/api/agent-credential-transitions/windows-package")
def download_windows_agent_credential_transition_package(
package_request: AgentWindowsPackageRequest,
http_request: Request,
current_user: EnrollmentManager,
) -> Response:
del current_user
browser_origin = http_request.headers.get("origin", "").strip()
try:
hub_url = normalize_hub_url(browser_origin)
except AgentWindowsPackageError as exc:
raise HTTPException(
status_code=400,
detail=(
"Windows Agent package downloads require "
"a valid browser Origin"
),
) from exc
try:
package = build_windows_agent_package(
enrollment_token=package_request.token,
hub_url=hub_url,
workflow="existing_agent_credential_transition",
)
except AgentWindowsPackageSourceError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except AgentWindowsPackageError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return Response(
content=package,
media_type="application/zip",
headers={
"Cache-Control": "no-store",
"Pragma": "no-cache",
"Content-Disposition": (
'attachment; filename="MinerOpsAgent-Credential-Upgrade-Windows.zip"'
),
},
)
@router.post(
"/api/agent/enroll",
response_model=AgentEnrollResponse,
+20 -3
View File
@@ -14,6 +14,7 @@ WINDOWS_PACKAGE_ROOT = "MinerOpsAgent-Windows"
WINDOWS_PACKAGE_FILES = (
"Enroll-MinerOpsAgent.ps1",
"Upgrade-MinerOpsAgentCredential.ps1",
"install.ps1",
"uninstall.ps1",
"MinerOpsAgentService.ps1",
@@ -80,6 +81,7 @@ def build_windows_agent_package(
*,
enrollment_token: str,
hub_url: str,
workflow: str = "new_agent",
source_root: Path | None = None,
) -> bytes:
token = enrollment_token.strip()
@@ -91,6 +93,12 @@ def build_windows_agent_package(
normalized_hub_url = normalize_hub_url(hub_url)
if workflow not in {
"new_agent",
"existing_agent_credential_transition",
}:
raise AgentWindowsPackageError("Unsupported Windows Agent workflow")
root = (
Path(source_root)
if source_root is not None
@@ -117,18 +125,27 @@ def build_windows_agent_package(
enrollment = {
"hub_url": normalized_hub_url,
"token": token,
"workflow": workflow,
}
instructions = """MinerOps Windows Agent
if workflow == "new_agent":
command = ".\\Enroll-MinerOpsAgent.ps1"
description = "enrollment"
else:
command = ".\\Upgrade-MinerOpsAgentCredential.ps1"
description = "existing-Agent credential transition"
instructions = f"""MinerOps Windows Agent
1. Extract this ZIP on the target Windows machine.
2. Open Windows PowerShell as Administrator.
3. Change to the extracted MinerOpsAgent-Windows folder.
4. Run:
.\\Enroll-MinerOpsAgent.ps1
{command}
The enrollment credential is short-lived and single-use.
This package is for the {description} workflow. The credential is short-lived
and single-use. Do not use the other bootstrap script with this package.
Do not post enrollment.json in chat, tickets, screenshots, or logs.
Delete the ZIP and extracted setup folder after successful installation.
"""
@@ -74,7 +74,7 @@ def agent_management_content(can_manage_agents: bool) -> str:
</h3>
</div>
<p>
<p id="agentEnrollmentResultCopy">
This token is displayed only for this enrollment step.
Keep it private. MinerOps does not store the plaintext token.
</p>
@@ -152,7 +152,9 @@ def agent_management_content(can_manage_agents: bool) -> str:
</button>
</div>
<div id="agentManagementContent" hidden>
<div id="agentManagementContent"
data-can-manage-agents="{str(can_manage_agents).lower()}"
hidden>
<section class="agent-summary-grid" aria-label="Agent Management summary">
<div class="panel agent-summary-card">
<span>Registered Agents</span>
@@ -197,6 +199,7 @@ def agent_management_content(can_manage_agents: bool) -> str:
<th>Last check-in</th>
<th>Credential</th>
<th>Routing</th>
<th>Action</th>
</tr>
</thead>
<tbody id="agentRows"></tbody>
+88 -12
View File
@@ -27,6 +27,8 @@
"agentCreateSubmit",
"agentCreateStatus",
"agentEnrollmentResult",
"agentEnrollmentResultTitle",
"agentEnrollmentResultCopy",
"agentEnrollmentToken",
"agentEnrollmentExpires",
"agentEnrollmentDownload",
@@ -48,6 +50,8 @@
};
let enrollments = [];
let packageEndpoint = "/api/agent-enrollments/windows-package";
let packageFilename = "MinerOpsAgent-Windows.zip";
function text(value, fallback) {
if (value === null || value === undefined || value === "") {
@@ -143,6 +147,27 @@
cell(words(agent.routing_state))
);
const action = document.createElement("td");
const canManage =
el.agentManagementContent.dataset.canManageAgents === "true";
if (
canManage &&
agent.credential_state === "legacy" &&
agent.routing_state === "assigned"
) {
const button = document.createElement("button");
button.type = "button";
button.className = "quiet-button";
button.textContent = "Upgrade credential";
button.addEventListener("click", function () {
createCredentialTransition(agent);
});
action.appendChild(button);
} else {
action.textContent = "—";
}
row.appendChild(action);
el.agentRows.appendChild(row);
});
@@ -188,7 +213,10 @@
cell(words(enrollment.status)),
cell(dateTime(enrollment.created_at)),
cell(dateTime(enrollment.expires_at)),
cell(text(enrollment.enrolled_agent_id, "Not enrolled"))
cell(text(
enrollment.enrolled_agent_id || enrollment.target_agent_id,
"Not enrolled"
))
);
el.agentEnrollmentRows.appendChild(row);
@@ -338,15 +366,15 @@
});
const enrollment = payload.data;
el.agentEnrollmentToken.textContent =
enrollment.enrollment_token;
el.agentEnrollmentExpires.textContent =
"Expires " + dateTime(enrollment.expires_at);
el.agentEnrollmentCopyStatus.textContent = "";
el.agentEnrollmentResult.hidden = false;
showOneTimeToken({
token: enrollment.enrollment_token,
expiresAt: enrollment.expires_at,
title: "Enrollment created",
copy: "This token creates a new Agent identity. Keep it private. " +
"MinerOps does not store the plaintext token.",
endpoint: "/api/agent-enrollments/windows-package",
filename: "MinerOpsAgent-Windows.zip"
});
el.agentCreateStatus.textContent = "Enrollment created.";
el.agentDisplayName.value = "";
@@ -360,6 +388,54 @@
}
}
function showOneTimeToken(options) {
el.agentEnrollmentToken.textContent = options.token;
el.agentEnrollmentExpires.textContent =
"Expires " + dateTime(options.expiresAt);
el.agentEnrollmentResultTitle.textContent = options.title;
el.agentEnrollmentResultCopy.textContent = options.copy;
el.agentEnrollmentCopyStatus.textContent = "";
el.agentEnrollmentResult.hidden = false;
packageEndpoint = options.endpoint;
packageFilename = options.filename;
}
async function createCredentialTransition(agent) {
const confirmed = window.confirm(
"Create a one-time credential upgrade for " + agent.agent_id +
"? Its Agent identity, Site, and Zone will remain unchanged."
);
if (!confirmed) return;
try {
const payload = await api("/api/agent-credential-transitions", {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify({agent_id: agent.agent_id})
});
const transition = payload.data;
showOneTimeToken({
token: transition.transition_token,
expiresAt: transition.expires_at,
title: "Existing Agent credential upgrade created",
copy: "This token is bound to the existing Agent identity " +
transition.target_agent_id + ". It cannot enroll a new " +
"Agent or transfer its Zone.",
endpoint: "/api/agent-credential-transitions/windows-package",
filename: "MinerOpsAgent-Credential-Upgrade-Windows.zip"
});
el.agentCreateStatus.textContent =
"Credential upgrade created for " + transition.target_agent_id + ".";
await refreshData(false);
} catch (error) {
el.agentCreateStatus.textContent =
error.message || "Credential upgrade could not be created.";
}
}
async function downloadWindowsPackage() {
const token = el.agentEnrollmentToken.textContent;
@@ -375,7 +451,7 @@
try {
const response = await fetch(
"/api/agent-enrollments/windows-package",
packageEndpoint,
{
method: "POST",
credentials: "same-origin",
@@ -408,7 +484,7 @@
const link = document.createElement("a");
link.href = downloadUrl;
link.download = "MinerOpsAgent-Windows.zip";
link.download = packageFilename;
document.body.appendChild(link);
link.click();
+2
View File
@@ -790,6 +790,8 @@ def required_api_permission(method: str, path: str) -> str | None:
return "agents.manage"
if path == "/api/agent-credential-transitions":
return "agents.manage"
if path == "/api/agent-credential-transitions/windows-package":
return "agents.manage"
if repair_parts_request(path):
return "repairs.read" if method == "GET" else "repairs.parts.manage"
if path.startswith("/api/admin/"):
@@ -62,6 +62,7 @@ class AgentManagementFrontendTests(unittest.TestCase):
self.assertIn('id="agentSite"', managed)
self.assertIn('id="agentZone"', managed)
self.assertIn('id="agentDisplayName"', managed)
self.assertIn('data-can-manage-agents="true"', managed)
self.assertIn("Linux x64 — coming later", managed)
self.assertIn("Linux ARM64 / Raspberry Pi — coming later", managed)
@@ -100,6 +101,11 @@ class AgentManagementFrontendTests(unittest.TestCase):
'api("/api/agent-enrollments", {',
source,
)
self.assertIn(
'api("/api/agent-credential-transitions", {',
source,
)
self.assertIn("Upgrade credential", source)
for unsafe in (
"innerHTML",
@@ -153,9 +159,10 @@ class AgentManagementFrontendTests(unittest.TestCase):
source,
)
self.assertIn(
'link.download = "MinerOpsAgent-Windows.zip"',
'packageFilename = "MinerOpsAgent-Windows.zip"',
source,
)
self.assertIn("link.download = packageFilename", source)
self.assertIn(
"window.URL.revokeObjectURL(downloadUrl)",
source,
@@ -65,6 +65,7 @@ class WindowsPackageBuilderTests(unittest.TestCase):
)
self.assertEqual(enrollment["token"], TOKEN)
self.assertEqual(enrollment["workflow"], "new_agent")
self.assertEqual(
enrollment["hub_url"],
"https://minerops.example.test",
@@ -77,6 +78,50 @@ class WindowsPackageBuilderTests(unittest.TestCase):
self.assertNotIn("api3_account", serialized)
self.assertNotIn("agent-config.json", "\n".join(names))
def test_existing_agent_transition_package_is_generic_and_distinct(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.source_tree(root)
package = build_windows_agent_package(
enrollment_token=TOKEN,
hub_url="https://minerops.example.test",
workflow="existing_agent_credential_transition",
source_root=root,
)
with zipfile.ZipFile(io.BytesIO(package)) as archive:
enrollment = json.loads(
archive.read(
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json"
)
)
instructions = archive.read(
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt"
).decode("utf-8")
names = set(archive.namelist())
self.assertEqual(
enrollment,
{
"hub_url": "https://minerops.example.test",
"token": TOKEN,
"workflow": "existing_agent_credential_transition",
},
)
self.assertIn("Upgrade-MinerOpsAgentCredential.ps1", instructions)
self.assertIn(
f"{WINDOWS_PACKAGE_ROOT}/Upgrade-MinerOpsAgentCredential.ps1",
names,
)
serialized = json.dumps(enrollment)
for forbidden in (
"plexus-windows-agent",
"site_id",
"agent_zone_id",
"agent_secret",
):
self.assertNotIn(forbidden, serialized)
def test_missing_canonical_source_fails_closed(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@@ -134,6 +179,13 @@ class WindowsPackageRouteTests(unittest.TestCase):
),
"agents.manage",
)
self.assertEqual(
required_api_permission(
"POST",
"/api/agent-credential-transitions/windows-package",
),
"agents.manage",
)
def test_view_only_user_cannot_download_package(self):
self.authenticate(("agents.view",))
@@ -180,6 +232,35 @@ class WindowsPackageRouteTests(unittest.TestCase):
)
)
self.assertEqual(enrollment["token"], TOKEN)
self.assertEqual(enrollment["workflow"], "new_agent")
def test_manager_downloads_distinct_existing_agent_upgrade_package(self):
self.authenticate(("agents.view", "agents.manage"))
response = self.client.post(
"/api/agent-credential-transitions/windows-package",
headers={"Origin": "http://testserver"},
json={"token": TOKEN},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["cache-control"], "no-store")
self.assertIn(
"MinerOpsAgent-Credential-Upgrade-Windows.zip",
response.headers["content-disposition"],
)
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
enrollment = json.loads(
archive.read(f"{WINDOWS_PACKAGE_ROOT}/enrollment.json")
)
instructions = archive.read(
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt"
).decode("utf-8")
self.assertEqual(
enrollment["workflow"],
"existing_agent_credential_transition",
)
self.assertIn("Upgrade-MinerOpsAgentCredential.ps1", instructions)
def test_package_requires_browser_origin(self):
self.authenticate(("agents.view", "agents.manage"))
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
project_name="${MINEROPS_CREDENTIAL_TRANSITION_PROJECT:-minerops-credential-transition-$$}"
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
cleanup() {
status=$?
if (( status != 0 )); then
"${compose[@]}" logs --no-color --tail 160 postgres || true
fi
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
exit "${status}"
}
trap cleanup EXIT
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
for source_file in \
backend/tests/test_agent_credential_transition_postgres.py \
agents/windows/Upgrade-MinerOpsAgentCredential.ps1; do
permissions="$(stat -c '%a' "${source_file}")"
(( (10#${permissions} % 10) & 4 )) || {
echo "${source_file} is not readable by the non-root test container" >&2
exit 1
}
done
"${compose[@]}" up --detach postgres
for attempt in $(seq 1 60); do
"${compose[@]}" exec -T postgres sh -ec \
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
sleep 1
done
"${compose[@]}" build backend
expected_head="$("${compose[@]}" run --rm --no-deps backend alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')"
[[ "${expected_head}" == "0020_existing_agent_transition" ]]
echo "Testing the additive 0019 -> 0020 transition-binding migration..."
"${compose[@]}" run --rm --no-deps backend alembic upgrade 0019_agent_legacy_auth_retire
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
echo "Testing production-shaped transactional credential transition..."
"${compose[@]}" run --rm --no-deps \
--volume "${repo_root}/backend/tests:/app/tests:ro" \
--env MINEROPS_CREDENTIAL_TRANSITION_DISPOSABLE=1 \
--env MINEROPS_AGENT_ID=plexus-windows-agent \
--env MINEROPS_AGENT_SHARED_SECRET=credential-transition-legacy-secret \
backend python -m unittest -v \
tests.test_agent_credential_transition_postgres
echo "Testing downgrade refusal after transition history exists..."
if output="$("${compose[@]}" run --rm --no-deps backend \
alembic downgrade 0019_agent_legacy_auth_retire 2>&1)"; then
echo "0020 downgrade unexpectedly discarded transition history" >&2
exit 1
fi
[[ "${output}" == *"existing-Agent credential transition history exists"* ]]
echo "Existing-Agent credential transition checks passed."
+6 -1
View File
@@ -37,6 +37,7 @@ echo "=== VERIFY CANONICAL SOURCE IS IN IMAGE ==="
"${compose[@]}" run --rm --no-deps \
backend sh -ec '
test -f /app/agents/windows/Enroll-MinerOpsAgent.ps1
test -f /app/agents/windows/Upgrade-MinerOpsAgentCredential.ps1
test -f /app/agents/windows/install.ps1
test -f /app/agents/windows/MinerOpsAgentService.ps1
test -f /app/agents/windows/VERSION
@@ -52,7 +53,9 @@ docker run --rm \
foreach ($script in @(
"/work/Enroll-MinerOpsAgent.ps1",
"/work/install.ps1"
"/work/Upgrade-MinerOpsAgentCredential.ps1",
"/work/install.ps1",
"/work/MinerOpsAgentGUI.ps1"
)) {
$tokens = $null
$errors = $null
@@ -70,6 +73,8 @@ docker run --rm \
}
& /work/tests/Test-MinerOpsEnrollment.ps1
& /work/tests/Test-MinerOpsBootEventParser.ps1
& /work/tests/Test-MinerOpsCredentialTransition.ps1
& /work/tests/Test-MinerOpsInstaller.ps1
'
+35
View File
@@ -4,7 +4,12 @@ set -euo pipefail
service="agents/windows/MinerOpsAgentService.ps1"
control="agents/windows/MinerOpsControl.ps1"
installer="agents/windows/install.ps1"
transition="agents/windows/Upgrade-MinerOpsAgentCredential.ps1"
gui="agents/windows/MinerOpsAgentGUI.ps1"
rg -q 'X-MinerOps-Agent-ID' "${service}"
rg -q 'X-MinerOps-Agent-ID' "${gui}"
rg -Fq '$config.agent_id' "${gui}"
rg -q 'X-MinerOps-Agent-Secret' "${gui}"
rg -Fq '$config.agent_zone_id' "${service}"
rg -Fq '$body["agent_zone_id"]' "${service}"
rg -q '"agent_zone_id"' agents/windows/config.example.json
@@ -28,9 +33,39 @@ rg -q 'could not restart restored MinerOpsAgent' "${installer}"
! rg -q 'Register-ScheduledTask.*-Force' "${installer}"
! rg -q 'agent_secret|api3_password|api3_account' "${installer}"
rg -q 'existing_agent_credential_transition' "${transition}"
rg -q '/api/agent/credential-transition' "${transition}"
rg -q 'RandomNumberGenerator' "${transition}"
rg -q 'agent-credential-transition.recovery.json' "${transition}"
rg -q 'minerops-credential-transition\.blocked' "${transition}"
rg -q 'Assert-MinerOpsTrustedAncestorNamespace' "${transition}"
rg -q 'Open-MinerOpsDirectoryLock' "${transition}"
rg -q 'Invoke-MinerOpsRecoverableConfigInstall' "${transition}"
rg -q 'active-moved-to-prior' "${transition}"
rg -q 'pending-moved-to-active' "${transition}"
! rg -q '\[IO\.File\]::Replace|Invoke-MinerOpsAtomicConfigReplace' "${transition}"
rg -q 'minerops-filesystem-trust\.blocked-' "${installer}"
rg -q 'ApproveFilesystemTrustResetAfterReboot' "${installer}"
rg -q 'windows-full-boot-event-v1:' "${installer}"
rg -q 'windows-full-boot-event-v1:' "${transition}"
! rg -q 'LastBootUpTime' "${installer}" "${transition}" \
agents/windows/Enroll-MinerOpsAgent.ps1
! rg -q 'Remove-MinerOpsInstallerTrustBlocker' "${installer}"
! rg -q 'Remove-MinerOpsEnrollmentTrustBlocker' \
agents/windows/Enroll-MinerOpsAgent.ps1
rg -q 'Remove-MinerOpsFilesystemTrustBlockerAfterConvergence' "${transition}"
rg -q 'GetNumberOfLinks' "${installer}"
rg -q 'Assert-MinerOpsInstallerTrustedAncestors' "${installer}"
! rg -q 'plexus-windows-agent|Plexus Mining Site' "${transition}"
rg -q 'Get-MinerOpsVerifiedScheduledTask' "${transition}"
! rg -q 'Get-ScheduledTask[^[:cntrl:]]*SilentlyContinue' "${transition}"
if command -v pwsh >/dev/null 2>&1; then
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsControl.ps1
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsInstaller.ps1
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsBootEventParser.ps1
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsCredentialTransition.ps1
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsNativeHarness.ps1
else
echo "pwsh is not installed; deterministic PowerShell harnesses retained but not executed."
fi