#Requires -RunAsAdministrator [CmdletBinding()] param( [switch]$ApproveFilesystemTrustResetAfterReboot ) $ErrorActionPreference = "Stop" $Target = "C:\MinerOpsAgent" $TaskName = "MinerOpsAgent" $RequiredFiles = @( "install.ps1", "MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1", "MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1", "MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1", "VERSION" ) # Authority is monotonic. Filesystem protection plus transition validation can # establish A or B; ACL repair by itself can only contain C or leave D blocked. $MinerOpsArtifactAuthority = [ordered]@{ TrustedCurrentTransitionState = "TrustedCurrentTransitionState" # A TrustedActiveConfig = "TrustedActiveConfig" # B ProtectedButUntrustedQuarantined = ` "ProtectedButUntrustedQuarantined" # C UnknownUntrusted = "UnknownUntrusted" # D ProtectedTransitionCandidate = "ProtectedTransitionCandidate" # not authority } function New-MinerOpsAgentSecret { $bytes = New-Object byte[] 32 $generator = [Security.Cryptography.RandomNumberGenerator]::Create() try { $generator.GetBytes($bytes) } finally { $generator.Dispose() } return [Convert]::ToBase64String($bytes).TrimEnd("=").Replace("+", "-").Replace("/", "_") } function Get-MinerOpsSha256 { param([Parameter(Mandatory = $true)][string]$Value) $sha = [Security.Cryptography.SHA256]::Create() try { $bytes = [Text.Encoding]::UTF8.GetBytes($Value) return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace("-", "").ToLowerInvariant() } finally { $sha.Dispose() } } function Assert-MinerOpsSingleLinkStream { param( [Parameter(Mandatory = $true)]$Stream, [Parameter(Mandatory = $true)][string]$Description ) if (-not ("MinerOpsFileIdentityNative" -as [type])) { Add-Type -TypeDefinition @' using System; using System.ComponentModel; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; public static class MinerOpsFileIdentityNative { [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 uint GetNumberOfLinks(SafeFileHandle file) { ByHandleFileInformation information; if (!GetFileInformationByHandle(file, out information)) throw new Win32Exception(Marshal.GetLastWin32Error()); return information.NumberOfLinks; } } '@ } $links = [MinerOpsFileIdentityNative]::GetNumberOfLinks( $Stream.SafeFileHandle ) if ($links -ne 1) { throw "$Description must have exactly one filesystem link." } } function Get-MinerOpsFilesystemTrustBlockerPaths { param([Parameter(Mandatory = $true)][string]$Path) $fullPath = [IO.Path]::GetFullPath($Path).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) $parent = [IO.Directory]::GetParent($fullPath) if ($null -eq $parent) { throw "Agent directory cannot be a filesystem root." } $pattern = ([IO.Path]::GetFileName($fullPath)) + ".minerops-filesystem-trust.blocked-*.state" return @(Get-ChildItem -LiteralPath $parent.FullName ` -Filter $pattern -File -Force -ErrorAction Stop) } function ConvertFrom-MinerOpsKernelBootEventRecord { param([Parameter(Mandatory = $true)]$EventRecord) if ( $null -eq $EventRecord -or $EventRecord.PSObject.Properties.Name -cnotcontains "ProviderName" -or $EventRecord.ProviderName -isnot [string] -or [string]$EventRecord.ProviderName -cne "Microsoft-Windows-Kernel-Boot" -or $EventRecord.PSObject.Properties.Name -cnotcontains "Id" -or $EventRecord.Id -isnot [int] -or [int]$EventRecord.Id -ne 27 -or $EventRecord.PSObject.Properties.Name -cnotcontains "RecordId" -or $EventRecord.RecordId -isnot [long] -or [long]$EventRecord.RecordId -le 0 -or $EventRecord.PSObject.Properties.Name -cnotcontains "Version" -or $EventRecord.Version -isnot [byte] -or [byte]$EventRecord.Version -notin @([byte]0, [byte]1) -or $EventRecord.PSObject.Methods.Name -cnotcontains "ToXml" ) { throw "Kernel-Boot event metadata is invalid or unsupported." } $xmlText = $EventRecord.ToXml() if ($xmlText -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$xmlText)) { throw "Kernel-Boot event XML is missing." } try { [xml]$eventXml = [string]$xmlText } catch { throw "Kernel-Boot event XML is malformed." } $eventNamespace = "http://schemas.microsoft.com/win/2004/08/events/event" if ( $null -eq $eventXml.DocumentElement -or $eventXml.DocumentElement.LocalName -cne "Event" -or $eventXml.DocumentElement.NamespaceURI -cne $eventNamespace ) { throw "Kernel-Boot event XML root is invalid." } $namespaceManager = [Xml.XmlNamespaceManager]::new($eventXml.NameTable) $namespaceManager.AddNamespace("evt", $eventNamespace) $eventNodes = @($eventXml.SelectNodes("/evt:Event", $namespaceManager)) $systemNodes = @($eventXml.SelectNodes( "/evt:Event/evt:System", $namespaceManager )) $eventDataNodes = @($eventXml.SelectNodes( "/evt:Event/evt:EventData", $namespaceManager )) $bootTypeNodes = @($eventXml.SelectNodes( "/evt:Event/evt:EventData/evt:Data[@Name='BootType']", $namespaceManager )) if ( $eventNodes.Count -ne 1 -or $systemNodes.Count -ne 1 -or $eventDataNodes.Count -ne 1 -or $bootTypeNodes.Count -ne 1 -or $bootTypeNodes[0].ChildNodes.Count -ne 1 -or $bootTypeNodes[0].FirstChild.NodeType -ne [Xml.XmlNodeType]::Text ) { throw "Kernel-Boot event XML schema is invalid or ambiguous." } $bootTypeText = [string]$bootTypeNodes[0].InnerText if ($bootTypeText -cnotmatch '^(0|[1-9][0-9]*)$') { throw "Kernel-Boot event BootType is not canonical." } $bootType = [UInt32]0 if (-not [UInt32]::TryParse( $bootTypeText, [Globalization.NumberStyles]::None, [Globalization.CultureInfo]::InvariantCulture, [ref]$bootType )) { throw "Kernel-Boot event BootType is invalid." } return [pscustomobject]@{ RecordId = [long]$EventRecord.RecordId EventId = [int]$EventRecord.Id ProviderName = [string]$EventRecord.ProviderName Version = [byte]$EventRecord.Version BootType = [UInt32]$bootType } } function ConvertTo-MinerOpsBootIdentifier { param( [Parameter(Mandatory = $true)]$RecordId, [Parameter(Mandatory = $true)]$EventId, [Parameter(Mandatory = $true)]$ProviderName, [Parameter(Mandatory = $true)]$Version, [Parameter(Mandatory = $true)]$BootType ) if ( $RecordId -isnot [long] -or [long]$RecordId -le 0 -or $EventId -isnot [int] -or [int]$EventId -ne 27 -or $ProviderName -isnot [string] -or [string]$ProviderName -cne "Microsoft-Windows-Kernel-Boot" -or $Version -isnot [byte] -or [byte]$Version -notin @([byte]0, [byte]1) -or (($BootType -isnot [int]) -and ($BootType -isnot [UInt32])) -or [UInt32]$BootType -ne 0 ) { throw "Windows full-kernel-boot event identity is invalid." } return "windows-full-boot-event-v1:{0:x16}" -f [UInt64]$RecordId } function ConvertFrom-MinerOpsBootIdentifier { param([Parameter(Mandatory = $true)]$Value) if ( $Value -isnot [string] -or $Value -cnotmatch '^windows-full-boot-event-v1:[0-9a-f]{16}$' ) { throw "Windows boot identity is not canonical." } $parsed = [UInt64]0 if (-not [UInt64]::TryParse( $Value.Substring(27), [Globalization.NumberStyles]::AllowHexSpecifier, [Globalization.CultureInfo]::InvariantCulture, [ref]$parsed ) -or $parsed -eq 0) { throw "Windows boot identity is not parseable." } return "windows-full-boot-event-v1:{0:x16}" -f $parsed } function Get-MinerOpsBootIdentifier { param([scriptblock]$QueryAction) try { if ($null -ne $QueryAction) { $records = @(& $QueryAction) } else { # Event 27 is emitted by the Windows Kernel-Boot provider with a # BootType payload. Typed EventRecord metadata and the Event Log # RecordId of the newest exact BootType 0 event anchor the most # recent full kernel boot. Sleep, hibernate, and Fast Startup do # not advance that full-boot anchor. $events = @(Get-WinEvent -FilterHashtable @{ LogName = "System" ProviderName = "Microsoft-Windows-Kernel-Boot" Id = 27 } -ErrorAction Stop) $records = @($events | ForEach-Object { ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord $_ }) } if ($records.Count -eq 0) { throw "Windows returned no Kernel-Boot event records." } $fullBoots = @($records | ForEach-Object { if ( $null -eq $_ -or $_.PSObject.Properties.Name -notcontains "RecordId" -or $_.PSObject.Properties.Name -notcontains "EventId" -or $_.PSObject.Properties.Name -notcontains "ProviderName" -or $_.PSObject.Properties.Name -notcontains "Version" -or $_.PSObject.Properties.Name -notcontains "BootType" -or $_.RecordId -isnot [long] -or [long]$_.RecordId -le 0 -or $_.EventId -isnot [int] -or [int]$_.EventId -ne 27 -or $_.ProviderName -isnot [string] -or [string]$_.ProviderName -cne "Microsoft-Windows-Kernel-Boot" -or $_.Version -isnot [byte] -or [byte]$_.Version -notin @([byte]0, [byte]1) -or (($_.BootType -isnot [int]) -and ($_.BootType -isnot [UInt32])) ) { throw "Windows Kernel-Boot query returned an invalid result." } if ( (($_.BootType -is [int]) -or ($_.BootType -is [UInt32])) -and [UInt32]$_.BootType -eq 0 ) { [void](ConvertTo-MinerOpsBootIdentifier ` -RecordId $_.RecordId -EventId $_.EventId ` -ProviderName $_.ProviderName -Version $_.Version ` -BootType $_.BootType) $_ } } | Sort-Object -Property { [UInt64]$_.RecordId } -Descending) if ($fullBoots.Count -eq 0) { throw "Windows returned no verifiable full-kernel-boot event." } for ($index = 1; $index -lt $fullBoots.Count; $index++) { if ([long]$fullBoots[$index - 1].RecordId -eq [long]$fullBoots[$index].RecordId) { throw "Windows full-kernel-boot event identity is ambiguous." } } return ConvertTo-MinerOpsBootIdentifier ` -RecordId $fullBoots[0].RecordId ` -EventId $fullBoots[0].EventId ` -ProviderName $fullBoots[0].ProviderName ` -Version $fullBoots[0].Version ` -BootType $fullBoots[0].BootType } catch { throw "Windows boot identity could not be verified." } } function Assert-MinerOpsBootEpochChanged { param( [Parameter(Mandatory = $true)]$StoredBootIdentifier, [Parameter(Mandatory = $true)]$CurrentBootIdentifier ) $stored = ConvertFrom-MinerOpsBootIdentifier -Value $StoredBootIdentifier $current = ConvertFrom-MinerOpsBootIdentifier -Value $CurrentBootIdentifier $storedRecordId = [UInt64]::Parse( $stored.Substring(27), [Globalization.NumberStyles]::AllowHexSpecifier, [Globalization.CultureInfo]::InvariantCulture ) $currentRecordId = [UInt64]::Parse( $current.Substring(27), [Globalization.NumberStyles]::AllowHexSpecifier, [Globalization.CultureInfo]::InvariantCulture ) if ($currentRecordId -eq $storedRecordId) { throw @" Filesystem trust reset was requested without a verified full Restart. DACL repair does not revoke pre-repair handles. Restart this host, then rerun with the explicit -ApproveFilesystemTrustResetAfterReboot switch. "@ } if ($currentRecordId -lt $storedRecordId) { throw @" Filesystem trust reset was requested with a boot-event record older than the stored anchor. Event-log loss, reset, restore, or record-number reuse requires safe operator intervention; automatic trust reset remains blocked. "@ } } function Assert-MinerOpsFilesystemTrustBlockerRecord { param( [Parameter(Mandatory = $true)]$Record, [Parameter(Mandatory = $true)][string]$TargetRoot ) if ($null -eq $Record) { throw "Filesystem trust blocker is invalid." } $required = @( "version", "state", "reason", "target_path", "detected_boot_id", "reset_boot_id", "original_task_running", "created_at" ) $actual = @($Record.PSObject.Properties.Name) if ( $actual.Count -ne $required.Count -or @($required | Where-Object { $actual -cnotcontains $_ }).Count -gt 0 -or (($Record.version -isnot [int]) -and ($Record.version -isnot [long])) -or [int64]$Record.version -ne 2 -or $Record.state -isnot [string] -or [string]$Record.state -cnotin @( "blocked", "reset-authorized", "runtime-published" ) -or $Record.reason -isnot [string] -or [string]$Record.reason -cne "existing-agent-directory-was-untrusted" -or $Record.target_path -isnot [string] ) { throw "Filesystem trust blocker is invalid." } try { $targetMatches = ([IO.Path]::GetFullPath( [string]$Record.target_path )).Equals( [IO.Path]::GetFullPath($TargetRoot), [StringComparison]::OrdinalIgnoreCase ) } catch { $targetMatches = $false } if (-not $targetMatches) { throw "Filesystem trust blocker is invalid." } $detected = ConvertFrom-MinerOpsBootIdentifier ` -Value $Record.detected_boot_id if ([string]$Record.state -ceq "blocked") { if ( $null -ne $Record.reset_boot_id -or $null -ne $Record.original_task_running ) { throw "Filesystem trust blocker containment state is invalid." } } else { $reset = ConvertFrom-MinerOpsBootIdentifier ` -Value $Record.reset_boot_id if ( $reset -ceq $detected -or $Record.original_task_running -isnot [bool] ) { throw "Filesystem trust blocker publication state is invalid." } } return $Record } function Set-MinerOpsFilesystemTrustBlockerResetAuthorized { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$TargetRoot, [Parameter(Mandatory = $true)][bool]$OriginalTaskRunning ) $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop if ( $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or ($item.PSObject.Properties.Name -contains "LinkType" -and [string]$item.LinkType) ) { throw "Filesystem trust blocker is not a regular single-link file." } $stream = $null try { $stream = Open-MinerOpsFileStreamWithRights ` -Path ([IO.Path]::GetFullPath($Path)) ` -Rights ([System.Security.AccessControl.FileSystemRights]( [System.Security.AccessControl.FileSystemRights]::ReadData -bor [System.Security.AccessControl.FileSystemRights]::WriteData -bor [System.Security.AccessControl.FileSystemRights]::ReadPermissions -bor [System.Security.AccessControl.FileSystemRights]::Synchronize )) Assert-MinerOpsSingleLinkStream -Stream $stream ` -Description "Filesystem trust blocker" if (-not (Test-MinerOpsCredentialAcl ` -Acl (Get-MinerOpsStreamAcl -Stream $stream))) { throw "Filesystem trust blocker protection is invalid." } $reader = New-Object IO.StreamReader( $stream, (New-Object Text.UTF8Encoding($false, $true)), $true, 4096, $true ) try { $text = $reader.ReadToEnd() } finally { $reader.Dispose() } $record = ConvertFrom-MinerOpsJson ` -Json $text -Description "Filesystem trust blocker" [void](Assert-MinerOpsFilesystemTrustBlockerRecord ` -Record $record -TargetRoot $TargetRoot) if ([string]$record.state -cne "blocked") { throw "Filesystem trust blocker authorization state was unexpected." } $currentBoot = Get-MinerOpsBootIdentifier Assert-MinerOpsBootEpochChanged ` -StoredBootIdentifier $record.detected_boot_id ` -CurrentBootIdentifier $currentBoot $updated = [ordered]@{ version = 2 state = "reset-authorized" reason = "existing-agent-directory-was-untrusted" target_path = [string]$record.target_path detected_boot_id = [string]$record.detected_boot_id reset_boot_id = $currentBoot original_task_running = $OriginalTaskRunning created_at = $record.created_at } $json = ($updated | ConvertTo-Json -Depth 10) + [Environment]::NewLine $bytes = (New-Object Text.UTF8Encoding($false)).GetBytes($json) $stream.Position = 0 $stream.SetLength(0) $stream.Write($bytes, 0, $bytes.Length) $stream.Flush($true) $stream.Position = 0 $verifyReader = New-Object IO.StreamReader( $stream, (New-Object Text.UTF8Encoding($false, $true)), $true, 4096, $true ) try { $verifyText = $verifyReader.ReadToEnd() } finally { $verifyReader.Dispose() } $verified = ConvertFrom-MinerOpsJson ` -Json $verifyText -Description "Filesystem trust blocker" [void](Assert-MinerOpsFilesystemTrustBlockerRecord ` -Record $verified -TargetRoot $TargetRoot) if ( [string]$verified.state -cne "reset-authorized" -or [string]$verified.reset_boot_id -cne $currentBoot -or [bool]$verified.original_task_running -ne $OriginalTaskRunning ) { throw "Filesystem trust blocker authorization was not durable." } } finally { if ($null -ne $stream) { $stream.Dispose() } } } function New-MinerOpsFilesystemTrustBlocker { param([Parameter(Mandatory = $true)][string]$TargetRoot) $fullTarget = [IO.Path]::GetFullPath($TargetRoot).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) $parent = [IO.Directory]::GetParent($fullTarget).FullName $path = Join-Path $parent ( (([IO.Path]::GetFileName($fullTarget)) + ".minerops-filesystem-trust.blocked-{0}.state") -f [guid]::NewGuid().ToString("N") ) $stream = $null try { # The blocker is a sibling of the contaminated directory, beneath its # already-verified trusted parent. It therefore cannot be deleted or # renamed through a handle opened inside the contaminated namespace. # This marker exists before ACL repair; repair is containment only. $stream = New-MinerOpsProtectedFileCreateNewStream ` -Path $path -Acl (New-MinerOpsCredentialAcl) if (-not (Test-MinerOpsCredentialAcl ` -Acl (Get-MinerOpsStreamAcl -Stream $stream))) { throw "Filesystem trust blocker ACL could not be verified." } Assert-MinerOpsSingleLinkStream -Stream $stream ` -Description "Filesystem trust blocker" $record = [ordered]@{ version = 2 state = "blocked" reason = "existing-agent-directory-was-untrusted" target_path = [IO.Path]::GetFullPath($TargetRoot) detected_boot_id = Get-MinerOpsBootIdentifier reset_boot_id = $null original_task_running = $null created_at = [DateTime]::UtcNow.ToString("o") } $json = ($record | ConvertTo-Json -Depth 10) + [Environment]::NewLine $bytes = (New-Object Text.UTF8Encoding($false)).GetBytes($json) $stream.Write($bytes, 0, $bytes.Length) $stream.Flush($true) } finally { if ($null -ne $stream) { $stream.Dispose() } } return $path } function Read-MinerOpsFilesystemTrustBlocker { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$TargetRoot ) $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop if ( $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or ($item.PSObject.Properties.Name -contains "LinkType" -and [string]$item.LinkType) ) { throw "Filesystem trust blocker is not a regular single-link file." } $stream = $null try { $stream = Open-MinerOpsFileStreamWithRights ` -Path ([IO.Path]::GetFullPath($Path)) ` -Rights ([System.Security.AccessControl.FileSystemRights]( [System.Security.AccessControl.FileSystemRights]::ReadData -bor [System.Security.AccessControl.FileSystemRights]::ReadPermissions -bor [System.Security.AccessControl.FileSystemRights]::Synchronize )) Assert-MinerOpsSingleLinkStream -Stream $stream ` -Description "Filesystem trust blocker" if (-not (Test-MinerOpsCredentialAcl ` -Acl (Get-MinerOpsStreamAcl -Stream $stream))) { throw "Filesystem trust blocker protection is invalid." } $reader = New-Object IO.StreamReader( $stream, (New-Object Text.UTF8Encoding($false, $true)), $true, 4096, $true ) try { $text = $reader.ReadToEnd() } finally { $reader.Dispose() } } finally { if ($null -ne $stream) { $stream.Dispose() } } $record = ConvertFrom-MinerOpsJson ` -Json $text -Description "Filesystem trust blocker" return Assert-MinerOpsFilesystemTrustBlockerRecord ` -Record $record -TargetRoot $TargetRoot } function Assert-MinerOpsFilesystemTrustResetApproved { param( [Parameter(Mandatory = $true)][string]$BlockerPath, [Parameter(Mandatory = $true)][string]$TargetRoot ) $record = Read-MinerOpsFilesystemTrustBlocker ` -Path $BlockerPath -TargetRoot $TargetRoot $currentBoot = Get-MinerOpsBootIdentifier Assert-MinerOpsBootEpochChanged ` -StoredBootIdentifier $record.detected_boot_id ` -CurrentBootIdentifier $currentBoot return $record } function Set-MinerOpsFilesystemTrustBlockerRuntimePublished { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$TargetRoot, [Parameter(Mandatory = $true)][bool]$OriginalTaskRunning ) $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop if ( $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or ($item.PSObject.Properties.Name -contains "LinkType" -and [string]$item.LinkType) ) { throw "Filesystem trust blocker is not a regular single-link file." } $stream = $null try { $stream = Open-MinerOpsFileStreamWithRights ` -Path ([IO.Path]::GetFullPath($Path)) ` -Rights ([System.Security.AccessControl.FileSystemRights]( [System.Security.AccessControl.FileSystemRights]::ReadData -bor [System.Security.AccessControl.FileSystemRights]::WriteData -bor [System.Security.AccessControl.FileSystemRights]::ReadPermissions -bor [System.Security.AccessControl.FileSystemRights]::Synchronize )) Assert-MinerOpsSingleLinkStream -Stream $stream ` -Description "Filesystem trust blocker" if (-not (Test-MinerOpsCredentialAcl ` -Acl (Get-MinerOpsStreamAcl -Stream $stream))) { throw "Filesystem trust blocker protection is invalid." } $reader = New-Object IO.StreamReader( $stream, (New-Object Text.UTF8Encoding($false, $true)), $true, 4096, $true ) try { $text = $reader.ReadToEnd() } finally { $reader.Dispose() } $record = ConvertFrom-MinerOpsJson ` -Json $text -Description "Filesystem trust blocker" [void](Assert-MinerOpsFilesystemTrustBlockerRecord ` -Record $record -TargetRoot $TargetRoot) if ( [string]$record.state -cne "reset-authorized" -or [bool]$record.original_task_running -ne $OriginalTaskRunning ) { throw "Filesystem trust blocker was not authorized by this parent." } $currentBoot = Get-MinerOpsBootIdentifier Assert-MinerOpsBootEpochChanged ` -StoredBootIdentifier $record.detected_boot_id ` -CurrentBootIdentifier $currentBoot $updated = [ordered]@{ version = 2 state = "runtime-published" reason = "existing-agent-directory-was-untrusted" target_path = [string]$record.target_path detected_boot_id = [string]$record.detected_boot_id reset_boot_id = $currentBoot original_task_running = $OriginalTaskRunning created_at = $record.created_at } $json = ($updated | ConvertTo-Json -Depth 10) + [Environment]::NewLine $bytes = (New-Object Text.UTF8Encoding($false)).GetBytes($json) $stream.Position = 0 $stream.SetLength(0) $stream.Write($bytes, 0, $bytes.Length) $stream.Flush($true) $stream.Position = 0 $verifyReader = New-Object IO.StreamReader( $stream, (New-Object Text.UTF8Encoding($false, $true)), $true, 4096, $true ) try { $verifyText = $verifyReader.ReadToEnd() } finally { $verifyReader.Dispose() } $verified = ConvertFrom-MinerOpsJson ` -Json $verifyText -Description "Filesystem trust blocker" [void](Assert-MinerOpsFilesystemTrustBlockerRecord ` -Record $verified -TargetRoot $TargetRoot) if ( [string]$verified.state -cne "runtime-published" -or [string]$verified.reset_boot_id -cne $currentBoot -or [bool]$verified.original_task_running -ne $OriginalTaskRunning ) { throw "Filesystem trust blocker publication state was not durable." } } finally { if ($null -ne $stream) { $stream.Dispose() } } } function Remove-MinerOpsFilesystemTrustBlockerAfterConvergence { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$TargetRoot ) $record = Read-MinerOpsFilesystemTrustBlocker ` -Path $Path -TargetRoot $TargetRoot if ([string]$record.state -cne "runtime-published") { throw "Filesystem trust blocker has not reached runtime publication." } Assert-MinerOpsBootEpochChanged ` -StoredBootIdentifier $record.detected_boot_id ` -CurrentBootIdentifier (Get-MinerOpsBootIdentifier) Remove-Item -LiteralPath $Path -Force -ErrorAction Stop if (Test-Path -LiteralPath $Path) { throw "Filesystem trust blocker could not be removed after convergence." } } function ConvertFrom-MinerOpsJson { param( [Parameter(Mandatory = $true)][string]$Json, [Parameter(Mandatory = $true)][string]$Description ) try { return $Json | ConvertFrom-Json } catch { # ConvertFrom-Json errors can quote input fragments. Never propagate # them for files which can contain credentials. throw "$Description is malformed JSON." } } function Get-MinerOpsCredentialSids { $values = @( "S-1-5-18", # Local System "S-1-5-32-544" # Built-in Administrators ) | Select-Object -Unique return @($values | ForEach-Object { New-Object System.Security.Principal.SecurityIdentifier($_) }) } function New-MinerOpsCredentialAcl { $acl = New-Object System.Security.AccessControl.FileSecurity $acl.SetAccessRuleProtection($true, $false) $administrators = New-Object System.Security.Principal.SecurityIdentifier( "S-1-5-32-544" ) $acl.SetOwner($administrators) foreach ($identity in (Get-MinerOpsCredentialSids)) { $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( $identity, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.Security.AccessControl.InheritanceFlags]::None, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow ) [void]$acl.AddAccessRule($rule) } return $acl } function New-MinerOpsAgentDirectoryAcl { $acl = New-Object System.Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) $administrators = New-Object System.Security.Principal.SecurityIdentifier( "S-1-5-32-544" ) $acl.SetOwner($administrators) foreach ($identity in (Get-MinerOpsCredentialSids)) { $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( $identity, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.Security.AccessControl.InheritanceFlags]"ContainerInherit, ObjectInherit", [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow ) [void]$acl.AddAccessRule($rule) } # Interactive users may enumerate and execute the installed runtime. Secret # files use protected, non-inheriting ACLs and remain unreadable. This ACE # deliberately grants no create, write, delete, rename, or ACL rights. $users = New-Object System.Security.Principal.SecurityIdentifier( "S-1-5-32-545" ) $readRule = New-Object System.Security.AccessControl.FileSystemAccessRule( $users, [System.Security.AccessControl.FileSystemRights]::ReadAndExecute, [System.Security.AccessControl.InheritanceFlags]"ContainerInherit, ObjectInherit", [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow ) [void]$acl.AddAccessRule($readRule) return $acl } function New-MinerOpsQuarantineDirectoryAcl { $acl = New-Object System.Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) $acl.SetOwner((New-Object ` System.Security.Principal.SecurityIdentifier("S-1-5-32-544"))) foreach ($identity in (Get-MinerOpsCredentialSids)) { $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( $identity, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.Security.AccessControl.InheritanceFlags]"ContainerInherit, ObjectInherit", [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow ) [void]$acl.AddAccessRule($rule) } return $acl } function Test-MinerOpsAgentDirectoryAcl { param([Parameter(Mandatory = $true)]$Acl) if (-not $Acl.AreAccessRulesProtected) { return $false } $trusted = @{} foreach ($sid in (Get-MinerOpsCredentialSids)) { $trusted[$sid.Value] = $false } $usersSid = "S-1-5-32-545" $usersRead = $false try { $owner = $Acl.GetOwner( [System.Security.Principal.SecurityIdentifier] ).Value } catch { return $false } if (-not $trusted.ContainsKey($owner)) { return $false } $writeLike = [System.Security.AccessControl.FileSystemRights]( [System.Security.AccessControl.FileSystemRights]::Write -bor [System.Security.AccessControl.FileSystemRights]::Delete -bor [System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership ) $rules = @($Acl.GetAccessRules( $true, $false, [System.Security.Principal.SecurityIdentifier] )) foreach ($rule in $rules) { $sid = $rule.IdentityReference.Value if ( $rule.IsInherited -or $rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or ($rule.InheritanceFlags -band [System.Security.AccessControl.InheritanceFlags]::ContainerInherit) -eq 0 -or ($rule.InheritanceFlags -band [System.Security.AccessControl.InheritanceFlags]::ObjectInherit) -eq 0 ) { return $false } if ($trusted.ContainsKey($sid)) { if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { return $false } $trusted[$sid] = $true continue } if ($sid -eq $usersSid) { if ( ($rule.FileSystemRights -band $writeLike) -ne 0 -or ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::ReadAndExecute) -ne [System.Security.AccessControl.FileSystemRights]::ReadAndExecute ) { return $false } $usersRead = $true continue } return $false } return $usersRead -and -not ($trusted.Values -contains $false) } function Assert-MinerOpsNoReparseDirectoryPath { param([Parameter(Mandatory = $true)][string]$Path) $fullPath = [IO.Path]::GetFullPath($Path) $root = [IO.Path]::GetPathRoot($fullPath) if (-not $root) { throw "Agent credential directory must use an absolute path." } if (-not $fullPath.Equals( $root, [StringComparison]::OrdinalIgnoreCase )) { $fullPath = $fullPath.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) } $current = $root $rootItem = Get-Item -LiteralPath $current -Force -ErrorAction Stop if ( -not $rootItem.PSIsContainer -or ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) ) { throw "Agent credential directory path root is a reparse point or non-directory component." } $relative = $fullPath.Substring($root.Length) $segments = @($relative.Split( @([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar), [StringSplitOptions]::RemoveEmptyEntries )) foreach ($segment in $segments) { $current = Join-Path $current $segment $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop if ( -not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or -not $item.FullName.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ).Equals( [IO.Path]::GetFullPath($current).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ), [StringComparison]::OrdinalIgnoreCase ) ) { throw "Agent credential directory path contains a reparse point or non-directory component." } } return $fullPath } function Get-MinerOpsTrustedNamespaceSids { return @( "S-1-5-18", # Local System "S-1-5-32-544", # Built-in Administrators # Windows Modules Installer owns protected Windows path components. "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value ) | Select-Object -Unique } function Assert-MinerOpsTrustedAncestorNamespace { param([Parameter(Mandatory = $true)][string]$Path) $fullPath = [IO.Path]::GetFullPath($Path) $root = [IO.Path]::GetPathRoot($fullPath) if (-not $fullPath.Equals( $root, [StringComparison]::OrdinalIgnoreCase )) { $fullPath = $fullPath.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) } $parentInfo = [IO.Directory]::GetParent($fullPath) if ($null -eq $parentInfo) { throw "Agent credential directory cannot be a filesystem root." } $parent = $parentInfo.FullName $root = [IO.Path]::GetPathRoot($parent) if (-not $parent.Equals( $root, [StringComparison]::OrdinalIgnoreCase )) { $parent = $parent.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) } $current = $root $components = [System.Collections.Generic.List[string]]::new() $components.Add($current) $relative = $parent.Substring($root.Length) foreach ($segment in @($relative.Split( @([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar), [StringSplitOptions]::RemoveEmptyEntries ))) { $current = Join-Path $current $segment $components.Add($current) } $trusted = @{} foreach ($sid in (Get-MinerOpsTrustedNamespaceSids)) { $trusted[$sid] = $true } $namespaceMutation = [System.Security.AccessControl.FileSystemRights]( [System.Security.AccessControl.FileSystemRights]::Delete -bor [System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership ) foreach ($component in $components) { $item = Get-Item -LiteralPath $component -Force -ErrorAction Stop if ( -not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) ) { throw "Agent credential directory ancestor is not a trusted directory." } $acl = Get-Acl -LiteralPath $component -ErrorAction Stop try { $owner = $acl.GetOwner( [System.Security.Principal.SecurityIdentifier] ).Value } catch { throw "Agent credential directory ancestor owner could not be verified." } if (-not $trusted.ContainsKey($owner)) { throw "Agent credential directory ancestor has an untrusted owner." } foreach ($rule in @($acl.GetAccessRules( $true, $true, [System.Security.Principal.SecurityIdentifier] ))) { if ( $rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or $trusted.ContainsKey($rule.IdentityReference.Value) -or ($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) ) { continue } if (($rule.FileSystemRights -band $namespaceMutation) -ne 0) { throw "Agent credential directory ancestor permits untrusted namespace replacement." } } } } function Open-MinerOpsDirectoryLock { param([Parameter(Mandatory = $true)][string]$Path) if (-not ("MinerOpsDirectoryLockNative" -as [type])) { Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; public static class MinerOpsDirectoryLockNative { [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern SafeFileHandle CreateFile( string name, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr templateFile); } '@ } # No FILE_SHARE_DELETE: the directory cannot be renamed or replaced while # an inherited/permissive DACL is being converted into the trust boundary. $handle = [MinerOpsDirectoryLockNative]::CreateFile( [IO.Path]::GetFullPath($Path), 0, 3, [IntPtr]::Zero, 3, 0x02200000, [IntPtr]::Zero ) if ($handle.IsInvalid) { $errorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error() $handle.Dispose() throw (New-Object ComponentModel.Win32Exception( $errorCode, "Agent credential directory could not be locked against replacement." )) } return $handle } function Assert-MinerOpsCredentialDirectory { param([Parameter(Mandatory = $true)][string]$Path) $fullPath = Assert-MinerOpsNoReparseDirectoryPath -Path $Path [void](Assert-MinerOpsTrustedAncestorNamespace -Path $fullPath) $acl = Get-Acl -LiteralPath $fullPath -ErrorAction Stop if (-not (Test-MinerOpsAgentDirectoryAcl -Acl $acl)) { throw "Agent credential directory owner or DACL is not trusted." } return $fullPath } function Initialize-MinerOpsCredentialDirectory { param( [Parameter(Mandatory = $true)][string]$Path, [scriptblock]$CheckpointAction, [switch]$ApproveFilesystemTrustResetAfterReboot ) $fullPath = Assert-MinerOpsNoReparseDirectoryPath -Path $Path [void](Assert-MinerOpsTrustedAncestorNamespace -Path $fullPath) $acl = Get-Acl -LiteralPath $fullPath -ErrorAction Stop $wasTrusted = Test-MinerOpsAgentDirectoryAcl -Acl $acl $newlyBlocked = $false if (-not $wasTrusted) { $directoryLock = $null $trustBlockerPath = New-MinerOpsFilesystemTrustBlocker ` -TargetRoot $fullPath $newlyBlocked = $true Invoke-MinerOpsTransitionCheckpoint ` -Name "directory-repair-blocker-written" ` -Action $CheckpointAction try { $directoryLock = Open-MinerOpsDirectoryLock -Path $fullPath [void](Assert-MinerOpsNoReparseDirectoryPath -Path $fullPath) [void](Assert-MinerOpsTrustedAncestorNamespace -Path $fullPath) Set-Acl -LiteralPath $fullPath ` -AclObject (New-MinerOpsAgentDirectoryAcl) [void](Assert-MinerOpsCredentialDirectory -Path $fullPath) Invoke-MinerOpsTransitionCheckpoint ` -Name "directory-trust-established" ` -Action $CheckpointAction } finally { if ($null -ne $directoryLock) { $directoryLock.Dispose() } } } [void](Assert-MinerOpsCredentialDirectory -Path $fullPath) $trustResetApproved = $false $trustResetAuthorized = $false $trustResetRuntimePublished = $false $trustResetOriginalTaskRunning = $null $trustBlockers = @( Get-MinerOpsFilesystemTrustBlockerPaths -Path $fullPath ) $trustEpochBlocked = $trustBlockers.Count -gt 0 if ($trustEpochBlocked -and -not $newlyBlocked) { if (-not $ApproveFilesystemTrustResetAfterReboot) { throw @" The Agent filesystem trust epoch is blocked because this existing directory was discovered untrusted. Ordinary reruns cannot revoke pre-repair handles. Follow the documented reboot or protected-recreation recovery procedure. "@ } $records = @($trustBlockers | ForEach-Object { Assert-MinerOpsFilesystemTrustResetApproved ` -BlockerPath $_.FullName -TargetRoot $fullPath }) $states = @($records | ForEach-Object { [string]$_.state } | Select-Object -Unique) if ($states.Count -ne 1) { throw "Filesystem trust blockers disagree about reset progress." } $trustResetAuthorized = $states[0] -cin @( "reset-authorized", "runtime-published" ) $trustResetRuntimePublished = $states[0] -ceq "runtime-published" if ($trustResetAuthorized) { $taskIntents = @($records | ForEach-Object { [bool]$_.original_task_running } | Select-Object -Unique) if ($taskIntents.Count -ne 1) { throw "Filesystem trust blockers disagree about task intent." } $trustResetOriginalTaskRunning = [bool]$taskIntents[0] } $trustResetApproved = $true } return [pscustomobject]@{ Path = $fullPath WasTrustedAtDiscovery = $wasTrusted WasRepaired = -not $wasTrusted TrustEpochBlocked = $trustEpochBlocked TrustEpochNewlyBlocked = $newlyBlocked TrustResetApproved = $trustResetApproved TrustResetAuthorized = $trustResetAuthorized TrustResetRuntimePublished = $trustResetRuntimePublished TrustResetOriginalTaskRunning = $trustResetOriginalTaskRunning TrustBlockerPaths = @($trustBlockers.FullName) } } function Set-MinerOpsStreamAcl { param( [Parameter(Mandatory = $true)]$Stream, [Parameter(Mandatory = $true)]$Acl ) if ($Stream.PSObject.Methods.Name -contains "SetAccessControl") { $Stream.SetAccessControl($Acl) return } [System.IO.FileSystemAclExtensions]::SetAccessControl($Stream, $Acl) } function New-MinerOpsProtectedFileCreateNewStream { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)] [System.Security.AccessControl.FileSecurity]$Acl ) $rights = [System.Security.AccessControl.FileSystemRights]( [System.Security.AccessControl.FileSystemRights]::WriteData -bor [System.Security.AccessControl.FileSystemRights]::ReadPermissions -bor [System.Security.AccessControl.FileSystemRights]::Synchronize ) return [IO.FileStream]::new( $Path, [IO.FileMode]::CreateNew, $rights, [IO.FileShare]::None, 4096, [IO.FileOptions]::None, $Acl ) } function Open-MinerOpsFileStreamWithRights { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)] [System.Security.AccessControl.FileSystemRights]$Rights ) return [IO.FileStream]::new( $Path, [IO.FileMode]::Open, $Rights, [IO.FileShare]::None, 4096, [IO.FileOptions]::None ) } function Get-MinerOpsStreamAcl { param([Parameter(Mandatory = $true)]$Stream) if ($Stream.PSObject.Methods.Name -contains "GetAccessControl") { return $Stream.GetAccessControl() } return [System.IO.FileSystemAclExtensions]::GetAccessControl($Stream) } function Test-MinerOpsCredentialAcl { param([Parameter(Mandatory = $true)]$Acl) if (-not $Acl.AreAccessRulesProtected) { return $false } $allowed = @{} foreach ($sid in (Get-MinerOpsCredentialSids)) { $allowed[$sid.Value] = $false } try { $owner = $Acl.GetOwner( [System.Security.Principal.SecurityIdentifier] ).Value } catch { return $false } if (-not $allowed.ContainsKey($owner)) { return $false } $rules = @($Acl.GetAccessRules( $true, $false, [System.Security.Principal.SecurityIdentifier] )) if ($rules.Count -ne $allowed.Count) { return $false } foreach ($rule in $rules) { $sid = $rule.IdentityReference.Value if ( -not $allowed.ContainsKey($sid) -or $rule.IsInherited -or $rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or $rule.FileSystemRights -ne [System.Security.AccessControl.FileSystemRights]::FullControl ) { return $false } $allowed[$sid] = $true } return -not ($allowed.Values -contains $false) } function Test-MinerOpsQuarantineDirectoryAcl { param([Parameter(Mandatory = $true)]$Acl) if (-not $Acl.AreAccessRulesProtected) { return $false } try { if ($Acl.GetOwner( [System.Security.Principal.SecurityIdentifier] ).Value -ne "S-1-5-32-544") { return $false } } catch { return $false } $expected = @{} foreach ($sid in (Get-MinerOpsCredentialSids)) { $expected[$sid.Value] = $false } $rules = @($Acl.GetAccessRules( $true, $false, [System.Security.Principal.SecurityIdentifier] )) if ($rules.Count -ne $expected.Count) { return $false } foreach ($rule in $rules) { $sid = $rule.IdentityReference.Value if ( -not $expected.ContainsKey($sid) -or $rule.IsInherited -or $rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or $rule.FileSystemRights -ne [System.Security.AccessControl.FileSystemRights]::FullControl -or ($rule.InheritanceFlags -band [System.Security.AccessControl.InheritanceFlags]::ContainerInherit) -eq 0 -or ($rule.InheritanceFlags -band [System.Security.AccessControl.InheritanceFlags]::ObjectInherit) -eq 0 ) { return $false } $expected[$sid] = $true } return -not ($expected.Values -contains $false) } function Initialize-MinerOpsQuarantineDirectory { param([Parameter(Mandatory = $true)][string]$TargetRoot) [void](Assert-MinerOpsCredentialDirectory -Path $TargetRoot) $path = Join-Path $TargetRoot "minerops-credential-transition-quarantine" if (-not (Test-Path -LiteralPath $path)) { (New-Object IO.DirectoryInfo($path)).Create( (New-MinerOpsQuarantineDirectoryAcl) ) } $directoryLock = $null try { $directoryLock = Open-MinerOpsDirectoryLock -Path $path $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop if ( -not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) ) { throw "Credential transition quarantine path is not a regular directory." } Set-Acl -LiteralPath $path ` -AclObject (New-MinerOpsQuarantineDirectoryAcl) if (-not (Test-MinerOpsQuarantineDirectoryAcl ` -Acl (Get-Acl -LiteralPath $path -ErrorAction Stop))) { throw "Credential transition quarantine directory ACL could not be verified." } } finally { if ($null -ne $directoryLock) { $directoryLock.Dispose() } } return [IO.Path]::GetFullPath($path) } function Assert-MinerOpsCredentialPath { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$ExpectedParent ) $fullPath = [IO.Path]::GetFullPath($Path) $fullParent = [IO.Path]::GetFullPath($ExpectedParent).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) $actualParent = [IO.Directory]::GetParent($fullPath).FullName.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) if (-not $actualParent.Equals( $fullParent, [StringComparison]::OrdinalIgnoreCase )) { throw "Credential transition artifact is outside the Agent directory." } $item = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop if ( $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or ( $item.PSObject.Properties.Name -contains "LinkType" -and [string]$item.LinkType ) -or -not $item.FullName.Equals( $fullPath, [StringComparison]::OrdinalIgnoreCase ) ) { throw "Credential transition artifact is not a regular file." } return $fullPath } function Open-MinerOpsProtectedCredentialFile { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$ExpectedParent, [switch]$AllowAclRepair ) $fullPath = Assert-MinerOpsCredentialPath ` -Path $Path -ExpectedParent $ExpectedParent $stream = $null try { $stream = Open-MinerOpsFileStreamWithRights ` -Path $fullPath ` -Rights ([System.Security.AccessControl.FileSystemRights]( [System.Security.AccessControl.FileSystemRights]::ReadData -bor [System.Security.AccessControl.FileSystemRights]::ReadPermissions -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership -bor [System.Security.AccessControl.FileSystemRights]::Synchronize )) Assert-MinerOpsSingleLinkStream -Stream $stream ` -Description "Credential transition artifact" # Recheck while the exclusive handle prevents replacement on Windows. [void](Assert-MinerOpsCredentialPath ` -Path $fullPath -ExpectedParent $ExpectedParent) $acl = Get-MinerOpsStreamAcl -Stream $stream $wasProtected = Test-MinerOpsCredentialAcl -Acl $acl if (-not $wasProtected) { Set-MinerOpsStreamAcl -Stream $stream ` -Acl (New-MinerOpsCredentialAcl) $verifiedAcl = Get-MinerOpsStreamAcl -Stream $stream if (-not (Test-MinerOpsCredentialAcl -Acl $verifiedAcl)) { throw "Credential transition artifact protection could not be verified." } if (-not $AllowAclRepair) { throw @" Credential transition artifact had an insecure owner or DACL. Its protection was repaired, but its contents will not be trusted. Review and remove the artifact before retrying. "@ } } return $stream } catch { if ($null -ne $stream) { $stream.Dispose() } throw } } function Get-MinerOpsCredentialArtifactProvenance { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$ExpectedParent, [Parameter(Mandatory = $true)] [ValidateSet("Transition", "Active", "Quarantine")] [string]$Role, [Parameter(Mandatory = $true)][bool]$DirectoryWasTrustedAtDiscovery, [switch]$CreatedByCurrentTransition ) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $MinerOpsArtifactAuthority.UnknownUntrusted } if ($Role -eq "Quarantine") { return $MinerOpsArtifactAuthority.ProtectedButUntrustedQuarantined } if ($Role -eq "Active") { # Active becomes class B only after server/transition validation. return $MinerOpsArtifactAuthority.UnknownUntrusted } if ($CreatedByCurrentTransition) { return $MinerOpsArtifactAuthority.ProtectedTransitionCandidate } if (-not $DirectoryWasTrustedAtDiscovery) { return $MinerOpsArtifactAuthority.UnknownUntrusted } try { $fullPath = Assert-MinerOpsCredentialPath ` -Path $Path -ExpectedParent $ExpectedParent } catch { return $MinerOpsArtifactAuthority.UnknownUntrusted } $stream = $null try { $stream = Open-MinerOpsFileStreamWithRights ` -Path $fullPath ` -Rights ([System.Security.AccessControl.FileSystemRights]( [System.Security.AccessControl.FileSystemRights]::ReadPermissions -bor [System.Security.AccessControl.FileSystemRights]::Synchronize )) Assert-MinerOpsSingleLinkStream -Stream $stream ` -Description "Credential transition artifact" [void](Assert-MinerOpsCredentialPath ` -Path $fullPath -ExpectedParent $ExpectedParent) if (-not (Test-MinerOpsCredentialAcl ` -Acl (Get-MinerOpsStreamAcl -Stream $stream))) { return $MinerOpsArtifactAuthority.UnknownUntrusted } return $MinerOpsArtifactAuthority.ProtectedTransitionCandidate } finally { if ($null -ne $stream) { $stream.Dispose() } } } function Move-MinerOpsArtifactToQuarantine { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$ExpectedParent, [Parameter(Mandatory = $true)][string]$Role, [scriptblock]$CheckpointAction ) if (-not (Test-Path -LiteralPath $Path)) { return $null } if (@(Get-MinerOpsQuarantineBlockers ` -TargetRoot $ExpectedParent).Count -eq 0) { throw "Credential transition quarantine requires a durable blocked-state marker." } # Never repair or read an untrusted artifact. Renaming it beneath a # protected, non-traversable quarantine directory contains exposure without # changing the security descriptor of another NTFS hard link to the same # file. The durable blocker already exists before any pathname mutation. $fullSource = [IO.Path]::GetFullPath($Path) $expected = [IO.Path]::GetFullPath($ExpectedParent).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) $actual = [IO.Directory]::GetParent($fullSource).FullName.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) $sourceItem = Get-Item -LiteralPath $fullSource -Force -ErrorAction Stop if ( -not $actual.Equals($expected, [StringComparison]::OrdinalIgnoreCase) -or $sourceItem.PSIsContainer -or ($sourceItem.Attributes -band [IO.FileAttributes]::ReparsePoint) ) { throw "Credential transition artifact cannot be quarantined safely." } [void](Assert-MinerOpsCredentialDirectory -Path $ExpectedParent) $quarantineRoot = Initialize-MinerOpsQuarantineDirectory ` -TargetRoot $ExpectedParent $safeRole = $Role -replace '[^a-zA-Z0-9-]', '-' $leaf = "minerops-quarantine-credential-transition-{0}-{1}-{2}.evidence" -f ` $safeRole, [DateTime]::UtcNow.ToString("yyyyMMddTHHmmssfffffffZ"), [guid]::NewGuid().ToString("N") $destination = Join-Path $quarantineRoot $leaf if (Test-Path -LiteralPath $destination) { throw "Credential transition quarantine destination already exists." } [IO.File]::Move( $fullSource, [IO.Path]::GetFullPath($destination) ) Invoke-MinerOpsTransitionCheckpoint ` -Name "artifact-moved-to-quarantine" ` -Action $CheckpointAction if ( (Test-Path -LiteralPath $Path) -or -not (Test-Path -LiteralPath $destination -PathType Leaf) ) { throw "Credential transition artifact could not be contained." } $destinationItem = Get-Item -LiteralPath $destination -Force -ErrorAction Stop if ( $destinationItem.PSIsContainer -or ($destinationItem.Attributes -band [IO.FileAttributes]::ReparsePoint) ) { throw "Credential transition quarantine result is not a regular file." } $isHardLink = ( $destinationItem.PSObject.Properties.Name -contains "LinkType" -and [string]$destinationItem.LinkType -eq "HardLink" ) if ($isHardLink) { throw @" A hard-linked artifact was removed from credential authority and retained under an unpredictable name in the protected quarantine directory. Its file ACL was not changed because that would also change every other hard link. Operator review is required; the durable blocked state remains in force. "@ } Set-Acl -LiteralPath $destination ` -AclObject (New-MinerOpsCredentialAcl) -ErrorAction Stop $verified = Open-MinerOpsProtectedCredentialFile ` -Path $destination -ExpectedParent $quarantineRoot $verified.Dispose() [void](Initialize-MinerOpsQuarantineDirectory -TargetRoot $ExpectedParent) Invoke-MinerOpsTransitionCheckpoint ` -Name "quarantine-directory-verified" ` -Action $CheckpointAction return $destination } function Get-MinerOpsQuarantineArtifacts { param([Parameter(Mandatory = $true)][string]$TargetRoot) $artifacts = [System.Collections.Generic.List[object]]::new() foreach ($legacy in @(Get-ChildItem ` -LiteralPath $TargetRoot ` -Filter "minerops-quarantine-credential-transition-*.evidence" ` -File ` -Force ` -ErrorAction Stop)) { $artifacts.Add($legacy) } $quarantineRoot = Join-Path ` $TargetRoot "minerops-credential-transition-quarantine" if (Test-Path -LiteralPath $quarantineRoot) { $item = Get-Item -LiteralPath $quarantineRoot -Force -ErrorAction Stop if ( -not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) ) { throw "Credential transition quarantine path is not a regular directory." } foreach ($evidence in @(Get-ChildItem ` -LiteralPath $quarantineRoot ` -File ` -Force ` -ErrorAction Stop)) { $artifacts.Add($evidence) } } return @($artifacts) } function Protect-MinerOpsQuarantineEvidence { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$QuarantineRoot ) $evidenceItem = Get-Item -LiteralPath $Path -Force -ErrorAction Stop $isHardLink = ( $evidenceItem.PSObject.Properties.Name -contains "LinkType" -and [string]$evidenceItem.LinkType -eq "HardLink" ) if ($isHardLink) { return } # Evidence can never regain authority. This retry only contains disclosure # if an earlier post-rename ACL update was interrupted or denied; contents # are never opened for read. Set-Acl -LiteralPath $Path ` -AclObject (New-MinerOpsCredentialAcl) -ErrorAction Stop $protectedEvidence = Open-MinerOpsProtectedCredentialFile ` -Path $Path -ExpectedParent $QuarantineRoot $protectedEvidence.Dispose() } function Get-MinerOpsQuarantineBlockers { param([Parameter(Mandatory = $true)][string]$TargetRoot) return @(Get-ChildItem ` -LiteralPath $TargetRoot ` -Filter "minerops-credential-transition.blocked*.state" ` -Force ` -ErrorAction Stop) } function Get-MinerOpsUnknownReplacementRemnants { param( [Parameter(Mandatory = $true)][string]$TargetRoot, [Parameter(Mandatory = $true)][string]$PendingPath, [Parameter(Mandatory = $true)][string]$PriorPath ) $pendingFull = [IO.Path]::GetFullPath($PendingPath) $priorFull = [IO.Path]::GetFullPath($PriorPath) return @(Get-ChildItem ` -LiteralPath $TargetRoot ` -File ` -Force ` -ErrorAction Stop | Where-Object { $full = [IO.Path]::GetFullPath($_.FullName) ( $_.Name -like "agent-config.json.*" -or $_.Name -like "agent-config.pre-credential-transition*" ) -and -not $full.Equals($pendingFull, [StringComparison]::OrdinalIgnoreCase) -and -not $full.Equals($priorFull, [StringComparison]::OrdinalIgnoreCase) }) } function Assert-MinerOpsNoQuarantineBlocker { param([Parameter(Mandatory = $true)][string]$TargetRoot) $quarantine = @(Get-MinerOpsQuarantineArtifacts -TargetRoot $TargetRoot) $blockers = @(Get-MinerOpsQuarantineBlockers -TargetRoot $TargetRoot) if ($quarantine.Count -gt 0 -or $blockers.Count -gt 0) { throw @" Credential transition evidence is quarantined in the Agent directory. It is non-authoritative and will never be used automatically. Preserve or remove the evidence only under an approved incident-recovery procedure, invalidate any possibly exposed transition authorization, obtain a new transition package, and then rerun this script. No quarantined contents were read or displayed. "@ } } function Read-MinerOpsProtectedCredentialText { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$ExpectedParent, [switch]$AllowAclRepair ) $stream = Open-MinerOpsProtectedCredentialFile ` -Path $Path -ExpectedParent $ExpectedParent ` -AllowAclRepair:$AllowAclRepair try { $stream.Position = 0 $reader = New-Object IO.StreamReader( $stream, (New-Object Text.UTF8Encoding($false, $true)), $true, 4096, $true ) try { return $reader.ReadToEnd() } finally { $reader.Dispose() } } finally { $stream.Dispose() } } function Write-MinerOpsProtectedCredentialJson { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$ExpectedParent, [Parameter(Mandatory = $true)]$Value ) $fullPath = [IO.Path]::GetFullPath($Path) $fullParent = [IO.Path]::GetFullPath($ExpectedParent).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) $actualParent = [IO.Directory]::GetParent($fullPath).FullName.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) if (-not $actualParent.Equals( $fullParent, [StringComparison]::OrdinalIgnoreCase )) { throw "Credential transition artifact is outside the Agent directory." } [void](Assert-MinerOpsCredentialDirectory -Path $fullParent) $stream = $null $protected = $false $createdByThisCall = $false try { # CreateNew refuses an attacker-controlled pathname. FileSecurity is # supplied to the native create so the file is protected from birth. $stream = New-MinerOpsProtectedFileCreateNewStream ` -Path $fullPath -Acl (New-MinerOpsCredentialAcl) $createdByThisCall = $true $verifiedAcl = Get-MinerOpsStreamAcl -Stream $stream if (-not (Test-MinerOpsCredentialAcl -Acl $verifiedAcl)) { throw "Credential transition artifact protection could not be verified." } Assert-MinerOpsSingleLinkStream -Stream $stream ` -Description "Credential transition artifact" $protected = $true $json = ($Value | ConvertTo-Json -Depth 50) + [Environment]::NewLine $bytes = (New-Object Text.UTF8Encoding($false)).GetBytes($json) $stream.Write($bytes, 0, $bytes.Length) $stream.Flush($true) } catch { if ($null -ne $stream) { $stream.Dispose() $stream = $null } # Before DACL verification the file is guaranteed to be empty. After # verification a partial write is protected; remove it so a normal # handled failure remains retryable. A hard crash is handled closed on # the next invocation. if ($createdByThisCall -and (Test-Path -LiteralPath $fullPath -PathType Leaf)) { Remove-Item -LiteralPath $fullPath -Force -ErrorAction SilentlyContinue } throw } finally { if ($null -ne $stream) { $stream.Dispose() } } if (-not $protected) { throw "Credential transition artifact was not protected before writing." } } function Ensure-MinerOpsQuarantineBlocker { param( [Parameter(Mandatory = $true)][string]$TargetRoot, [Parameter(Mandatory = $true)][string]$Reason, [scriptblock]$CheckpointAction ) [void](Assert-MinerOpsCredentialDirectory -Path $TargetRoot) $existing = @(Get-MinerOpsQuarantineBlockers -TargetRoot $TargetRoot) if ($existing.Count -gt 0) { return $existing[0].FullName } $path = Join-Path $TargetRoot ( "minerops-credential-transition.blocked-{0}.state" -f [guid]::NewGuid().ToString("N") ) Write-MinerOpsProtectedCredentialJson ` -Path $path ` -ExpectedParent $TargetRoot ` -Value ([ordered]@{ version = 1 state = "blocked" reason = $Reason created_at = [DateTime]::UtcNow.ToString("o") }) Invoke-MinerOpsTransitionCheckpoint ` -Name "quarantine-blocker-written" -Action $CheckpointAction return $path } function Remove-MinerOpsProtectedCredentialArtifact { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$ExpectedParent ) if (-not (Test-Path -LiteralPath $Path)) { return } $stream = Open-MinerOpsProtectedCredentialFile ` -Path $Path -ExpectedParent $ExpectedParent -AllowAclRepair $stream.Dispose() [void](Assert-MinerOpsCredentialDirectory -Path $ExpectedParent) Remove-Item -LiteralPath $Path -Force -ErrorAction Stop if (Test-Path -LiteralPath $Path) { throw "Credential transition artifact could not be removed." } } function Get-MinerOpsConfigProjectionHash { param([Parameter(Mandatory = $true)]$Config) $transitionFields = @( "hub_url", "agent_id", "site_id", "agent_zone_id", "site_name", "agent_secret" ) $projection = [ordered]@{} foreach ($property in $Config.PSObject.Properties) { if ($property.Name -notin $transitionFields) { $projection[$property.Name] = $property.Value } } return Get-MinerOpsSha256 -Value ( $projection | ConvertTo-Json -Depth 50 -Compress ) } function Get-MinerOpsConfigHash { param([Parameter(Mandatory = $true)]$Config) return Get-MinerOpsSha256 -Value ( $Config | ConvertTo-Json -Depth 50 -Compress ) } function Assert-MinerOpsRecoveryRecord { param([Parameter(Mandatory = $true)]$Recovery) $required = @( "version", "workflow", "hub_url", "agent_id", "site_id", "agent_zone_id", "original_task_running", "transition_token", "transition_token_hash", "agent_secret", "agent_secret_hash", "original_config_hash", "preserved_config_hash", "created_at" ) $actual = @($Recovery.PSObject.Properties.Name) if ( @($required | Where-Object { $_ -notin $actual }).Count -gt 0 -or @($actual | Where-Object { $_ -notin $required }).Count -gt 0 ) { throw "Credential transition recovery state has an invalid schema." } if ( [int]$Recovery.version -ne 1 -or [string]$Recovery.workflow -ne "existing_agent_credential_transition" -or -not ([string]$Recovery.hub_url).Trim() -or -not ([string]$Recovery.agent_id).Trim() -or $Recovery.original_task_running -isnot [bool] -or ([string]$Recovery.transition_token).Length -lt 32 -or ([string]$Recovery.transition_token).Length -gt 256 -or -not ([string]$Recovery.agent_secret).Trim() -or [string]$Recovery.transition_token_hash -ne ( Get-MinerOpsSha256 -Value ([string]$Recovery.transition_token) ) -or [string]$Recovery.agent_secret_hash -ne ( Get-MinerOpsSha256 -Value ([string]$Recovery.agent_secret) ) -or [string]$Recovery.original_config_hash -notmatch "^[0-9a-f]{64}$" -or [string]$Recovery.preserved_config_hash -notmatch "^[0-9a-f]{64}$" ) { throw "Credential transition recovery state is invalid." } $created = [DateTime]::MinValue if (-not [DateTime]::TryParse( [string]$Recovery.created_at, [ref]$created )) { throw "Credential transition recovery state has an invalid timestamp." } } function Assert-MinerOpsEnrollment { param( [Parameter(Mandatory = $true)]$Enrollment, [Parameter(Mandatory = $true)][string]$HubUrl, [Parameter(Mandatory = $true)][string]$Token ) if ( [string]$Enrollment.workflow -ne "existing_agent_credential_transition" -or ([string]$Enrollment.hub_url).TrimEnd("/") -ne $HubUrl -or ([string]$Enrollment.token).Trim() -ne $Token ) { throw "enrollment.json does not match the protected transition state." } } function Assert-MinerOpsConfigMatchesRecovery { param( [Parameter(Mandatory = $true)]$Config, [Parameter(Mandatory = $true)]$Recovery, [Parameter(Mandatory = $true)][bool]$AllowCommitted ) if ( ([string]$Config.hub_url).TrimEnd("/") -ne [string]$Recovery.hub_url -or ([string]$Config.agent_id).Trim() -ne [string]$Recovery.agent_id -or (Get-MinerOpsConfigProjectionHash -Config $Config) -ne [string]$Recovery.preserved_config_hash ) { throw "Active Agent config does not match the protected recovery state." } $hasCommittedSecret = [string]$Config.agent_secret -eq [string]$Recovery.agent_secret if ($hasCommittedSecret) { if (-not $AllowCommitted) { throw "Active Agent config is already in the committed state." } return $true } if ((Get-MinerOpsConfigHash -Config $Config) -ne [string]$Recovery.original_config_hash) { throw "Legacy Agent config changed after transition recovery began." } return $false } function Assert-MinerOpsPendingMatchesRecovery { param( [Parameter(Mandatory = $true)]$Pending, [Parameter(Mandatory = $true)]$Recovery ) if ( ([string]$Pending.hub_url).TrimEnd("/") -ne [string]$Recovery.hub_url -or ([string]$Pending.agent_id).Trim() -ne [string]$Recovery.agent_id -or [string]$Pending.agent_secret -ne [string]$Recovery.agent_secret -or (Get-MinerOpsSha256 -Value ([string]$Pending.agent_secret)) -ne [string]$Recovery.agent_secret_hash -or (Get-MinerOpsConfigProjectionHash -Config $Pending) -ne [string]$Recovery.preserved_config_hash ) { throw "Pending Agent config does not match protected recovery state." } } function Assert-MinerOpsTransitionResponse { param( [Parameter(Mandatory = $true)]$Agent, [Parameter(Mandatory = $true)]$Recovery ) foreach ($required in @( "agent_id", "site_id", "site_name", "agent_zone_id", "agent_zone_name" )) { if (-not ([string]$Agent.$required).Trim()) { throw "MinerOps credential transition response is missing $required." } } if ([string]$Agent.agent_id -ne [string]$Recovery.agent_id) { throw "Credential transition response changed the existing Agent identity." } if ( [string]$Recovery.site_id -and [string]$Agent.site_id -ne [string]$Recovery.site_id ) { throw "Credential transition response changed the existing Site identity." } if ( [string]$Recovery.agent_zone_id -and [string]$Agent.agent_zone_id -ne [string]$Recovery.agent_zone_id ) { throw "Credential transition response changed the existing Agent Zone." } } function New-MinerOpsUpdatedConfig { param( [Parameter(Mandatory = $true)]$ExistingConfig, [Parameter(Mandatory = $true)]$Agent, [Parameter(Mandatory = $true)]$Recovery ) $updated = [ordered]@{} foreach ($property in $ExistingConfig.PSObject.Properties) { $updated[$property.Name] = $property.Value } $updated["hub_url"] = [string]$Recovery.hub_url $updated["agent_id"] = [string]$Agent.agent_id $updated["site_id"] = [string]$Agent.site_id $updated["agent_zone_id"] = [string]$Agent.agent_zone_id $updated["site_name"] = [string]$Agent.site_name $updated["agent_secret"] = [string]$Recovery.agent_secret return [pscustomobject]$updated } function Test-MinerOpsJsonEquivalent { param( [Parameter(Mandatory = $true)]$Left, [Parameter(Mandatory = $true)]$Right ) return ($Left | ConvertTo-Json -Depth 50 -Compress) -ceq ($Right | ConvertTo-Json -Depth 50 -Compress) } function ConvertFrom-MinerOpsScheduledTaskText { param( [Parameter(Mandatory = $true)]$Value, [Parameter(Mandatory = $true)][string]$Description, [switch]$AllowProviderEnum ) if ($Value -is [string]) { return [string]$Value } if ($AllowProviderEnum -and $null -ne $Value -and $Value.GetType().IsEnum) { return $Value.ToString() } throw "$Description has an invalid type." } function Get-MinerOpsVerifiedScheduledTask { param( [Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][string]$TargetRoot ) try { # Enumerating with terminating error behavior distinguishes an empty # exact-name result from provider, access, and query failures. $tasks = @(Get-ScheduledTask -ErrorAction Stop | Where-Object { $null -ne $_ -and $_.PSObject.Properties.Name -contains "TaskName" -and $_.TaskName -is [string] -and [string]$_.TaskName -ceq $Name }) } catch { throw "Scheduled Task $Name query failed; verify Task Scheduler access and provider health." } if ($tasks.Count -eq 0) { throw "Scheduled Task $Name is missing." } if ($tasks.Count -ne 1) { throw "Scheduled Task $Name query returned multiple unexpected results." } $task = $tasks[0] if ( $null -eq $task -or $task.PSObject.Properties.Name -notcontains "TaskName" -or $task.TaskName -isnot [string] -or [string]$task.TaskName -cne $Name -or $task.PSObject.Properties.Name -notcontains "State" -or ($task.State -is [string] -and -not $task.State.Trim()) ) { throw "Scheduled Task $Name returned a malformed identity or state." } if ( $task.PSObject.Properties.Name -notcontains "TaskPath" -or $null -eq $task.TaskPath -or $task.TaskPath -isnot [string] -or [string]$task.TaskPath -cne "\" ) { throw "Scheduled Task $Name is registered under an unexpected task path." } $state = ConvertFrom-MinerOpsScheduledTaskText ` -Value $task.State -Description "Scheduled Task $Name state" ` -AllowProviderEnum if ($state -notin @("Running", "Ready", "Disabled", "Queued", "Unknown")) { throw "Scheduled Task $Name returned unexpected state '$state'." } if ( $null -eq $task.Settings -or $task.Settings -is [string] -or $task.Settings -is [ValueType] -or $task.Settings.PSObject.Properties.Name -notcontains "Enabled" -or $task.Settings.Enabled -isnot [bool] -or $task.Settings.Enabled -ne $true -or $state -eq "Disabled" ) { throw "Scheduled Task $Name is disabled or lacks valid enabled settings." } if ($task.PSObject.Properties.Name -notcontains "Actions") { throw "Scheduled Task $Name must contain exactly one executable action." } $actions = @($task.Actions) if ( $actions.Count -ne 1 -or $null -eq $actions[0] -or $actions[0] -is [string] -or $actions[0] -is [ValueType] ) { throw "Scheduled Task $Name must contain exactly one executable action." } $action = $actions[0] if ( $action.PSObject.Properties.Name -notcontains "Execute" -or $action.PSObject.Properties.Name -notcontains "Arguments" -or $action.Execute -isnot [string] -or $action.Arguments -isnot [string] -or -not ([string]$action.Execute).Trim() ) { throw "Scheduled Task $Name action is malformed." } $expectedExecutable = [IO.Path]::GetFullPath( "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" ) $actualExecutable = [IO.Path]::GetFullPath( [Environment]::ExpandEnvironmentVariables([string]$action.Execute) ) if (-not $actualExecutable.Equals( $expectedExecutable, [StringComparison]::OrdinalIgnoreCase )) { throw "Scheduled Task $Name uses an unexpected executable." } $expectedArguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f (Join-Path ([IO.Path]::GetFullPath($TargetRoot)) ` "MinerOpsAgentService.ps1") if ([string]$action.Arguments -cne $expectedArguments) { throw "Scheduled Task $Name uses unexpected script arguments." } if ( $null -eq $task.Principal -or $task.Principal -is [string] -or $task.Principal -is [ValueType] -or $task.Principal.PSObject.Properties.Name -notcontains "UserId" -or $task.Principal.UserId -isnot [string] -or [string]$task.Principal.UserId -notin @( "SYSTEM", "NT AUTHORITY\SYSTEM", "S-1-5-18" ) -or $task.Principal.PSObject.Properties.Name -notcontains "LogonType" -or $task.Principal.PSObject.Properties.Name -notcontains "RunLevel" ) { throw "Scheduled Task $Name uses an unexpected principal." } $logonType = ConvertFrom-MinerOpsScheduledTaskText ` -Value $task.Principal.LogonType ` -Description "Scheduled Task $Name principal logon type" ` -AllowProviderEnum $runLevel = ConvertFrom-MinerOpsScheduledTaskText ` -Value $task.Principal.RunLevel ` -Description "Scheduled Task $Name principal run level" ` -AllowProviderEnum if ($logonType -cne "ServiceAccount" -or $runLevel -cne "Highest") { throw "Scheduled Task $Name uses an unexpected principal." } return [pscustomobject]@{ Task = $task State = $state } } function Assert-MinerOpsScheduledTaskIntent { param( [Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][string]$TargetRoot, [Parameter(Mandatory = $true)][bool]$ShouldRun ) $verified = Get-MinerOpsVerifiedScheduledTask ` -Name $Name -TargetRoot $TargetRoot $expected = if ($ShouldRun) { "Running" } else { "Ready" } if ([string]$verified.State -ne $expected) { throw "Scheduled Task $Name is '$($verified.State)'; expected '$expected'." } return $verified } function Set-MinerOpsScheduledTaskState { param( [Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][string]$TargetRoot, [Parameter(Mandatory = $true)][bool]$ShouldRun, [TimeSpan]$Timeout = [TimeSpan]::FromSeconds(30) ) $verified = Get-MinerOpsVerifiedScheduledTask ` -Name $Name -TargetRoot $TargetRoot $state = [string]$verified.State if ($ShouldRun -and $state -eq "Ready") { Start-ScheduledTask -TaskName $Name -ErrorAction Stop } elseif (-not $ShouldRun -and $state -eq "Running") { Stop-ScheduledTask -TaskName $Name -ErrorAction Stop } elseif ($state -notin @("Running", "Ready")) { throw "Scheduled Task $Name cannot converge from state '$state'." } $deadline = [DateTime]::UtcNow.Add($Timeout) do { $verified = Get-MinerOpsVerifiedScheduledTask ` -Name $Name -TargetRoot $TargetRoot $expected = if ($ShouldRun) { "Running" } else { "Ready" } if ([string]$verified.State -eq $expected) { return $verified } if ([string]$verified.State -notin @("Running", "Ready")) { throw "Scheduled Task $Name entered invalid state '$($verified.State)'." } Start-Sleep -Milliseconds 500 } while ([DateTime]::UtcNow -lt $deadline) throw "Scheduled Task $Name did not become $expected within $($Timeout.TotalSeconds) seconds." } function Stop-MinerOpsAgentTask { param( [Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][string]$TargetRoot ) Set-MinerOpsScheduledTaskState ` -Name $Name -TargetRoot $TargetRoot -ShouldRun $false } function Invoke-MinerOpsRuntimeInstaller { param( [Parameter(Mandatory = $true)][string]$Path, [switch]$ApproveFilesystemTrustResetAfterReboot ) & $Path ` -ApproveFilesystemTrustResetAfterReboot:$ApproveFilesystemTrustResetAfterReboot } function Invoke-MinerOpsCredentialRedemption { param( [Parameter(Mandatory = $true)][string]$HubUrl, [Parameter(Mandatory = $true)][string]$Token, [Parameter(Mandatory = $true)][string]$SecretHash ) $body = @{ token = $Token agent_secret_hash = $SecretHash } | ConvertTo-Json -Compress try { return Invoke-RestMethod ` -Method Post ` -Uri "$HubUrl/api/agent/credential-transition" ` -ContentType "application/json" ` -Body $body ` -TimeoutSec 30 } finally { $body = $null } } function Invoke-MinerOpsRecoverableConfigInstall { param( [Parameter(Mandatory = $true)][string]$PendingPath, [Parameter(Mandatory = $true)][string]$ConfigPath, [Parameter(Mandatory = $true)][string]$PriorPath, [Parameter(Mandatory = $true)][string]$TargetRoot, [scriptblock]$CheckpointAction ) # ReplaceFileW has documented failures in which the active pathname is # missing or the replaced file survives under a different name. A pair of # same-directory renames gives every crash state a stable, authenticated # name. The restrictive parent DACL prevents an unprivileged pathname swap # after handles used for validation have closed. if (Test-Path -LiteralPath $ConfigPath) { if (Test-Path -LiteralPath $PriorPath) { throw "Active config and prior replacement remnant cannot coexist before installation." } [void](Assert-MinerOpsCredentialDirectory -Path $TargetRoot) [IO.File]::Move( [IO.Path]::GetFullPath($ConfigPath), [IO.Path]::GetFullPath($PriorPath) ) Invoke-MinerOpsTransitionCheckpoint ` -Name "active-moved-to-prior" -Action $CheckpointAction } if (-not (Test-Path -LiteralPath $PendingPath -PathType Leaf)) { throw "Pending Agent config is missing during recoverable installation." } [void](Assert-MinerOpsCredentialDirectory -Path $TargetRoot) [IO.File]::Move( [IO.Path]::GetFullPath($PendingPath), [IO.Path]::GetFullPath($ConfigPath) ) Invoke-MinerOpsTransitionCheckpoint ` -Name "pending-moved-to-active" -Action $CheckpointAction } function Invoke-MinerOpsTransitionCheckpoint { param( [Parameter(Mandatory = $true)][string]$Name, [scriptblock]$Action ) if ($null -ne $Action) { & $Action $Name } } function Invoke-MinerOpsAgentCredentialTransition { param( [string]$TargetRoot = $Target, [string]$PackageRoot = $PSScriptRoot, [string]$ScheduledTaskName = $TaskName, [scriptblock]$CheckpointAction, [switch]$ApproveFilesystemTrustResetAfterReboot ) $configPath = Join-Path $TargetRoot "agent-config.json" $pendingPath = Join-Path $TargetRoot "agent-config.json.pending" $recoveryPath = Join-Path $TargetRoot "agent-credential-transition.recovery.json" $priorPath = Join-Path $TargetRoot "agent-config.pre-credential-transition.json" $enrollmentPath = Join-Path $PackageRoot "enrollment.json" $installer = Join-Path $PackageRoot "install.ps1" foreach ($name in $RequiredFiles) { $source = Join-Path $PackageRoot $name if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { throw "Credential transition package is missing $name" } } if (-not (Test-Path -LiteralPath $TargetRoot -PathType Container)) { throw "Existing Agent directory is missing: $TargetRoot" } $directoryState = Initialize-MinerOpsCredentialDirectory ` -Path $TargetRoot ` -CheckpointAction $CheckpointAction ` -ApproveFilesystemTrustResetAfterReboot:$ApproveFilesystemTrustResetAfterReboot $TargetRoot = [string]$directoryState.Path $configPath = Join-Path $TargetRoot "agent-config.json" $pendingPath = Join-Path $TargetRoot "agent-config.json.pending" $recoveryPath = Join-Path $TargetRoot "agent-credential-transition.recovery.json" $priorPath = Join-Path $TargetRoot "agent-config.pre-credential-transition.json" # Provenance is established before content discovery. ACL repair contains # exposure but never promotes an artifact. A directory repair similarly # invalidates every pre-existing authoritative transition pathname. $securityEvent = $false $forceQuarantine = ( -not [bool]$directoryState.WasTrustedAtDiscovery -or ([bool]$directoryState.TrustEpochBlocked -and -not [bool]$directoryState.TrustResetAuthorized) -or @(Get-MinerOpsQuarantineBlockers -TargetRoot $TargetRoot).Count -gt 0 ) foreach ($artifact in @( @{ Path = $recoveryPath; Role = "recovery" }, @{ Path = $pendingPath; Role = "pending" }, @{ Path = $priorPath; Role = "prior-remnant" } )) { if (-not (Test-Path -LiteralPath $artifact.Path)) { continue } $provenance = $MinerOpsArtifactAuthority.UnknownUntrusted if (-not $forceQuarantine) { $provenance = Get-MinerOpsCredentialArtifactProvenance ` -Path $artifact.Path ` -ExpectedParent $TargetRoot ` -Role "Transition" ` -DirectoryWasTrustedAtDiscovery ` ([bool]$directoryState.WasTrustedAtDiscovery) } if ( $forceQuarantine -or $provenance -ne ` $MinerOpsArtifactAuthority.ProtectedTransitionCandidate ) { $securityEvent = $true [void](Ensure-MinerOpsQuarantineBlocker ` -TargetRoot $TargetRoot ` -Reason "untrusted-$($artifact.Role)" ` -CheckpointAction $CheckpointAction) [void](Move-MinerOpsArtifactToQuarantine ` -Path $artifact.Path ` -ExpectedParent $TargetRoot ` -Role $artifact.Role ` -CheckpointAction $CheckpointAction) } } foreach ($unknown in @(Get-MinerOpsUnknownReplacementRemnants ` -TargetRoot $TargetRoot ` -PendingPath $pendingPath ` -PriorPath $priorPath)) { $securityEvent = $true [void](Ensure-MinerOpsQuarantineBlocker ` -TargetRoot $TargetRoot ` -Reason "unknown-replacement-remnant" ` -CheckpointAction $CheckpointAction) [void](Move-MinerOpsArtifactToQuarantine ` -Path $unknown.FullName ` -ExpectedParent $TargetRoot ` -Role "unknown-remnant" ` -CheckpointAction $CheckpointAction) } $quarantineDirectory = Join-Path ` $TargetRoot "minerops-credential-transition-quarantine" if (Test-Path -LiteralPath $quarantineDirectory) { $securityEvent = $true [void](Ensure-MinerOpsQuarantineBlocker ` -TargetRoot $TargetRoot ` -Reason "quarantine-directory-present" ` -CheckpointAction $CheckpointAction) [void](Initialize-MinerOpsQuarantineDirectory ` -TargetRoot $TargetRoot) } $quarantineEvidence = @(Get-MinerOpsQuarantineArtifacts ` -TargetRoot $TargetRoot) if ($quarantineEvidence.Count -gt 0) { $securityEvent = $true [void](Ensure-MinerOpsQuarantineBlocker ` -TargetRoot $TargetRoot ` -Reason "quarantined-evidence-present" ` -CheckpointAction $CheckpointAction) } # Active config is not transition authority by pathname alone. Contain its # ACL before fallible evidence handling; the external trust blocker is not # removed here, and active contents are not read in this repair epoch. if (Test-Path -LiteralPath $configPath -PathType Leaf) { $activeContainment = Open-MinerOpsProtectedCredentialFile ` -Path $configPath -ExpectedParent $TargetRoot -AllowAclRepair $activeContainment.Dispose() } foreach ($evidence in $quarantineEvidence) { # Older interrupted candidates left evidence directly in TargetRoot. # Move it under the protected quarantine directory without reading or # changing the evidence file's own ACL. Evidence already under that # directory needs no file-level ACL mutation. $evidenceParent = [IO.Directory]::GetParent( [IO.Path]::GetFullPath($evidence.FullName) ).FullName.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) $targetFull = [IO.Path]::GetFullPath($TargetRoot).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) if ($evidenceParent.Equals( $targetFull, [StringComparison]::OrdinalIgnoreCase )) { [void](Move-MinerOpsArtifactToQuarantine ` -Path $evidence.FullName ` -ExpectedParent $TargetRoot ` -Role "legacy-quarantine-evidence" ` -CheckpointAction $CheckpointAction) } else { Protect-MinerOpsQuarantineEvidence ` -Path $evidence.FullName ` -QuarantineRoot $quarantineDirectory } } Invoke-MinerOpsTransitionCheckpoint ` -Name "directory-containment-scan-complete" ` -Action $CheckpointAction $runtimeInstallerCompleted = $false $preResetOriginalTaskRunning = $null if ([bool]$directoryState.TrustEpochBlocked) { if ([bool]$directoryState.TrustEpochNewlyBlocked) { throw @" The existing Agent directory was untrusted. Its DACL was hardened only to contain the incident, and known transition artifacts were made non-authoritative. The external filesystem trust blocker remains. Reboot and follow the explicit trust-reset procedure; this invocation cannot enroll, redeem, generate credentials, activate config, or publish runtime content. "@ } if (-not [bool]$directoryState.TrustResetApproved) { throw "Filesystem trust reset was not explicitly approved." } # A reboot revokes pre-repair handles, but it does not authenticate # files which arrived before that reboot. Any transition artifact, # remnant, evidence, or internal blocker keeps the external blocker in # force and requires incident recovery plus a fresh package. Assert-MinerOpsNoQuarantineBlocker -TargetRoot $TargetRoot if ($securityEvent) { throw "Filesystem trust reset found non-authoritative transition state; operator recovery is required." } $preResetTask = Get-MinerOpsVerifiedScheduledTask ` -Name $ScheduledTaskName -TargetRoot $TargetRoot if ([string]$preResetTask.State -notin @("Running", "Ready")) { throw "Scheduled Task $ScheduledTaskName has no valid pre-reset intent." } if ([bool]$directoryState.TrustResetAuthorized) { $preResetOriginalTaskRunning = ` [bool]$directoryState.TrustResetOriginalTaskRunning } else { $preResetOriginalTaskRunning = ( [string]$preResetTask.State -eq "Running" ) foreach ($blockerPath in @($directoryState.TrustBlockerPaths)) { Set-MinerOpsFilesystemTrustBlockerResetAuthorized ` -Path ([string]$blockerPath) ` -TargetRoot $TargetRoot ` -OriginalTaskRunning ([bool]$preResetOriginalTaskRunning) } } Invoke-MinerOpsTransitionCheckpoint ` -Name "trust-reset-authorized" -Action $CheckpointAction if ([string]$preResetTask.State -eq "Running") { Stop-MinerOpsAgentTask ` -Name $ScheduledTaskName -TargetRoot $TargetRoot } # The installer only publishes package runtime while the external # blocker remains. It has no deletion authority. The parent records # publication and durable task intent so an interrupted parent run can # resume, but this does not itself complete the trust reset. Invoke-MinerOpsRuntimeInstaller ` -Path $installer ` -ApproveFilesystemTrustResetAfterReboot $remainingBlockers = @( Get-MinerOpsFilesystemTrustBlockerPaths -Path $TargetRoot ) if ($remainingBlockers.Count -eq 0) { throw "Runtime installer removed the parent-owned trust blocker." } if (-not [bool]$directoryState.TrustResetRuntimePublished) { foreach ($blockerPath in @($directoryState.TrustBlockerPaths)) { Set-MinerOpsFilesystemTrustBlockerRuntimePublished ` -Path ([string]$blockerPath) ` -TargetRoot $TargetRoot ` -OriginalTaskRunning ([bool]$preResetOriginalTaskRunning) } } Invoke-MinerOpsTransitionCheckpoint ` -Name "trust-reset-runtime-published" ` -Action $CheckpointAction $runtimeInstallerCompleted = $true } Assert-MinerOpsNoQuarantineBlocker -TargetRoot $TargetRoot # Recovery is intentionally discovered before requiring active config so # ERROR_UNABLE_TO_MOVE_REPLACEMENT-style active-missing states can resume. $recovery = $null $recoveryAuthorityClass = $MinerOpsArtifactAuthority.UnknownUntrusted $recoveryExists = Test-Path -LiteralPath $recoveryPath -PathType Leaf if ($recoveryExists) { $recoveryText = Read-MinerOpsProtectedCredentialText ` -Path $recoveryPath -ExpectedParent $TargetRoot if (-not $recoveryText.Trim()) { if ( (Test-Path -LiteralPath $pendingPath) -or (Test-Path -LiteralPath $priorPath) ) { throw "Empty recovery state cannot authorize replacement artifacts." } Remove-MinerOpsProtectedCredentialArtifact ` -Path $recoveryPath -ExpectedParent $TargetRoot $recoveryExists = $false } else { $recovery = ConvertFrom-MinerOpsJson ` -Json $recoveryText ` -Description "Credential transition recovery state" Assert-MinerOpsRecoveryRecord -Recovery $recovery $recoveryAuthorityClass = ` $MinerOpsArtifactAuthority.TrustedCurrentTransitionState } } $existingConfig = $null $activeAuthorityClass = $MinerOpsArtifactAuthority.UnknownUntrusted $activeExists = Test-Path -LiteralPath $configPath -PathType Leaf if ($activeExists) { $activeAuthorityClass = Get-MinerOpsCredentialArtifactProvenance ` -Path $configPath ` -ExpectedParent $TargetRoot ` -Role "Active" ` -DirectoryWasTrustedAtDiscovery ` ([bool]$directoryState.WasTrustedAtDiscovery) # Active config has independent server/package identity binding. It is # the only credential artifact for which containment repair may be # followed by a read. $existingText = Read-MinerOpsProtectedCredentialText ` -Path $configPath -ExpectedParent $TargetRoot -AllowAclRepair $existingConfig = ConvertFrom-MinerOpsJson ` -Json $existingText -Description "Existing Agent config" } elseif (-not $recoveryExists) { throw "Existing Agent config is missing and no trusted recovery state exists." } $enrollment = $null if (Test-Path -LiteralPath $enrollmentPath -PathType Leaf) { $enrollmentText = Get-Content -LiteralPath $enrollmentPath -Raw $enrollment = ConvertFrom-MinerOpsJson ` -Json $enrollmentText -Description "enrollment.json" } if ($recoveryExists) { $hubUrl = ([string]$recovery.hub_url).TrimEnd("/") $enrollmentToken = [string]$recovery.transition_token $existingAgentId = ([string]$recovery.agent_id).Trim() if ($null -ne $enrollment) { Assert-MinerOpsEnrollment -Enrollment $enrollment ` -HubUrl $hubUrl -Token $enrollmentToken } if ($activeExists -and ( ([string]$existingConfig.hub_url).TrimEnd("/") -ne $hubUrl -or ([string]$existingConfig.agent_id).Trim() -ne $existingAgentId )) { throw "Credential transition recovery state does not match this Agent." } } else { foreach ($orphan in @($pendingPath, $priorPath)) { if (Test-Path -LiteralPath $orphan) { [void](Move-MinerOpsArtifactToQuarantine ` -Path $orphan ` -ExpectedParent $TargetRoot ` -Role "orphan-without-recovery" ` -CheckpointAction $CheckpointAction) } } Assert-MinerOpsNoQuarantineBlocker -TargetRoot $TargetRoot $existingAgentId = ([string]$existingConfig.agent_id).Trim() $existingHubUrl = ([string]$existingConfig.hub_url).TrimEnd("/") if (-not $existingAgentId) { throw "Existing Agent config has no durable agent_id." } if ($null -eq $enrollment) { throw "Credential transition package is missing enrollment.json" } if ([string]$enrollment.workflow -ne "existing_agent_credential_transition") { throw @" This package is not authorized for an existing-Agent credential transition. Use the bootstrap script named by INSTALL.txt. "@ } $hubUrl = ([string]$enrollment.hub_url).TrimEnd("/") $enrollmentToken = ([string]$enrollment.token).Trim() if ($hubUrl -ne $existingHubUrl) { throw "Transition package Hub does not match the existing Agent config." } if ($enrollmentToken.Length -lt 32 -or $enrollmentToken.Length -gt 256) { throw "enrollment.json contains an invalid transition token." } } $parsedHub = $null if ( -not [Uri]::TryCreate($hubUrl, [UriKind]::Absolute, [ref]$parsedHub) -or $parsedHub.Scheme -notin @("http", "https") ) { throw "Credential transition contains an invalid MinerOps Hub URL." } $originalTask = Get-MinerOpsVerifiedScheduledTask ` -Name $ScheduledTaskName -TargetRoot $TargetRoot if ($null -ne $preResetOriginalTaskRunning) { if ([string]$originalTask.State -ne "Ready") { throw "Trust-reset installer did not leave the verified Agent task stopped." } $originalTaskRunning = [bool]$preResetOriginalTaskRunning } elseif ([string]$originalTask.State -notin @("Running", "Ready")) { throw "Scheduled Task $ScheduledTaskName has no valid running or intentionally stopped state." } else { $originalTaskRunning = [string]$originalTask.State -eq "Running" } if (-not $recoveryExists) { $candidateSecret = New-MinerOpsAgentSecret $recovery = [ordered]@{ version = 1 workflow = "existing_agent_credential_transition" hub_url = $hubUrl agent_id = $existingAgentId site_id = [string]$existingConfig.site_id agent_zone_id = [string]$existingConfig.agent_zone_id original_task_running = $originalTaskRunning transition_token = $enrollmentToken transition_token_hash = Get-MinerOpsSha256 -Value $enrollmentToken agent_secret = $candidateSecret agent_secret_hash = Get-MinerOpsSha256 -Value $candidateSecret original_config_hash = Get-MinerOpsConfigHash -Config $existingConfig preserved_config_hash = Get-MinerOpsConfigProjectionHash ` -Config $existingConfig created_at = [DateTime]::UtcNow.ToString("o") } Write-MinerOpsProtectedCredentialJson ` -Path $recoveryPath -ExpectedParent $TargetRoot -Value $recovery $recovery = [pscustomobject]$recovery $recoveryExists = $true $recoveryAuthorityClass = ` $MinerOpsArtifactAuthority.TrustedCurrentTransitionState Invoke-MinerOpsTransitionCheckpoint ` -Name "recovery-written" -Action $CheckpointAction } Assert-MinerOpsRecoveryRecord -Recovery $recovery if ( $null -ne $preResetOriginalTaskRunning -and [bool]$recovery.original_task_running -ne [bool]$preResetOriginalTaskRunning ) { throw "Credential recovery task intent disagrees with the trust blocker." } if ($recoveryAuthorityClass -ne ` $MinerOpsArtifactAuthority.TrustedCurrentTransitionState) { throw "Credential transition recovery authority was not established." } $activeCommitted = $false if ($activeExists) { $activeCommitted = Assert-MinerOpsConfigMatchesRecovery ` -Config $existingConfig -Recovery $recovery -AllowCommitted $true } $pendingConfig = $null $pendingAuthorityClass = $MinerOpsArtifactAuthority.UnknownUntrusted if (Test-Path -LiteralPath $pendingPath -PathType Leaf) { $pendingText = Read-MinerOpsProtectedCredentialText ` -Path $pendingPath -ExpectedParent $TargetRoot if (-not $pendingText.Trim()) { Remove-MinerOpsProtectedCredentialArtifact ` -Path $pendingPath -ExpectedParent $TargetRoot } else { $pendingConfig = ConvertFrom-MinerOpsJson ` -Json $pendingText -Description "Pending Agent config" Assert-MinerOpsPendingMatchesRecovery ` -Pending $pendingConfig -Recovery $recovery $pendingAuthorityClass = ` $MinerOpsArtifactAuthority.TrustedCurrentTransitionState } } $priorConfig = $null if (Test-Path -LiteralPath $priorPath -PathType Leaf) { $priorText = Read-MinerOpsProtectedCredentialText ` -Path $priorPath -ExpectedParent $TargetRoot if (-not $priorText.Trim()) { throw "Known replacement remnant is empty." } $priorConfig = ConvertFrom-MinerOpsJson ` -Json $priorText -Description "Prior Agent config remnant" $priorCommitted = Assert-MinerOpsConfigMatchesRecovery ` -Config $priorConfig -Recovery $recovery -AllowCommitted $false if ($priorCommitted) { throw "Prior replacement remnant unexpectedly contains committed credentials." } } if (-not $activeExists -and $null -eq $pendingConfig) { throw "Active config is missing and no authenticated pending config can restore it." } if ( $null -ne $pendingConfig -and $pendingAuthorityClass -ne ` $MinerOpsArtifactAuthority.TrustedCurrentTransitionState ) { throw "Pending Agent config authority was not established." } if ($activeCommitted -and $null -ne $pendingConfig) { throw "Committed active config and pending config coexist unexpectedly." } if ($activeExists -and $null -ne $priorConfig -and -not $activeCommitted) { throw "Legacy active config and prior replacement remnant coexist unexpectedly." } if (-not $activeExists -and $null -ne $priorConfig -and $null -eq $pendingConfig) { throw "Replacement remnant cannot restore a missing active config by itself." } $redemptionAttempted = $false $activeVerified = $false $taskVerified = $false try { # install.ps1 validates its sources and restores its own task changes on # failure. The durable task intent above predates this call. if (-not $runtimeInstallerCompleted) { Invoke-MinerOpsRuntimeInstaller -Path $installer } Stop-MinerOpsAgentTask ` -Name $ScheduledTaskName -TargetRoot $TargetRoot Invoke-MinerOpsTransitionCheckpoint ` -Name "task-stopped" -Action $CheckpointAction Write-Host "Redeeming one-time existing-Agent credential transition..." $redemptionAttempted = $true $response = Invoke-MinerOpsCredentialRedemption ` -HubUrl $hubUrl ` -Token ([string]$recovery.transition_token) ` -SecretHash ([string]$recovery.agent_secret_hash) if ($null -eq $response.data) { throw "MinerOps credential transition returned no Agent data." } $agent = $response.data Assert-MinerOpsTransitionResponse -Agent $agent -Recovery $recovery Invoke-MinerOpsTransitionCheckpoint ` -Name "server-committed" -Action $CheckpointAction if ($activeCommitted) { $expectedConfig = New-MinerOpsUpdatedConfig ` -ExistingConfig $existingConfig -Agent $agent -Recovery $recovery if (-not (Test-MinerOpsJsonEquivalent ` -Left $existingConfig -Right $expectedConfig)) { throw "Active Agent config does not match the committed transition." } } else { $baseConfig = if ($null -ne $pendingConfig) { $pendingConfig } else { $existingConfig } $expectedConfig = New-MinerOpsUpdatedConfig ` -ExistingConfig $baseConfig -Agent $agent -Recovery $recovery if ($null -ne $pendingConfig) { if (-not (Test-MinerOpsJsonEquivalent ` -Left $pendingConfig -Right $expectedConfig)) { throw "Pending Agent config does not match the committed transition." } } else { Write-MinerOpsProtectedCredentialJson ` -Path $pendingPath ` -ExpectedParent $TargetRoot ` -Value $expectedConfig $pendingConfig = $expectedConfig $pendingAuthorityClass = ` $MinerOpsArtifactAuthority.TrustedCurrentTransitionState Invoke-MinerOpsTransitionCheckpoint ` -Name "pending-written" -Action $CheckpointAction } Invoke-MinerOpsTransitionCheckpoint ` -Name "before-replace" -Action $CheckpointAction Invoke-MinerOpsRecoverableConfigInstall ` -PendingPath $pendingPath ` -ConfigPath $configPath ` -PriorPath $priorPath ` -TargetRoot $TargetRoot ` -CheckpointAction $CheckpointAction Invoke-MinerOpsTransitionCheckpoint ` -Name "after-replace" -Action $CheckpointAction } $activeText = Read-MinerOpsProtectedCredentialText ` -Path $configPath -ExpectedParent $TargetRoot $activeConfig = ConvertFrom-MinerOpsJson ` -Json $activeText -Description "Active Agent config" [void](Assert-MinerOpsConfigMatchesRecovery ` -Config $activeConfig -Recovery $recovery -AllowCommitted $true) $verifiedExpected = New-MinerOpsUpdatedConfig ` -ExistingConfig $activeConfig -Agent $agent -Recovery $recovery if (-not (Test-MinerOpsJsonEquivalent ` -Left $activeConfig -Right $verifiedExpected)) { throw "Active Agent config replacement could not be verified." } $activeVerified = $true $activeAuthorityClass = $MinerOpsArtifactAuthority.TrustedActiveConfig Set-MinerOpsScheduledTaskState ` -Name $ScheduledTaskName ` -TargetRoot $TargetRoot ` -ShouldRun ([bool]$recovery.original_task_running) # The same strict verifier is used for discovery, restoration polling, # retry, and the final pre-cleanup success decision. [void](Assert-MinerOpsScheduledTaskIntent ` -Name $ScheduledTaskName ` -TargetRoot $TargetRoot ` -ShouldRun ([bool]$recovery.original_task_running)) $taskVerified = $true Invoke-MinerOpsTransitionCheckpoint ` -Name "task-restored" -Action $CheckpointAction if ( -not $activeVerified -or $activeAuthorityClass -ne ` $MinerOpsArtifactAuthority.TrustedActiveConfig -or -not $taskVerified ) { throw "Credential transition success criteria were not verified." } Remove-MinerOpsProtectedCredentialArtifact ` -Path $priorPath -ExpectedParent $TargetRoot Remove-MinerOpsProtectedCredentialArtifact ` -Path $pendingPath -ExpectedParent $TargetRoot if (Test-Path -LiteralPath $enrollmentPath) { Remove-Item -LiteralPath $enrollmentPath -Force -ErrorAction Stop } # This parent is the sole deletion authority. The blocker remains # through boot authorization, strict task validation, runtime # publication, credential convergence, active-config verification, # final task restoration, and non-recovery cleanup. Recovery remains # last so a blocker-removal or final-cleanup failure is retryable. if ([bool]$directoryState.TrustEpochBlocked) { Invoke-MinerOpsTransitionCheckpoint ` -Name "before-trust-blocker-removal" ` -Action $CheckpointAction foreach ($blockerPath in @($directoryState.TrustBlockerPaths)) { Remove-MinerOpsFilesystemTrustBlockerAfterConvergence ` -Path ([string]$blockerPath) -TargetRoot $TargetRoot } if (@(Get-MinerOpsFilesystemTrustBlockerPaths ` -Path $TargetRoot).Count -gt 0) { throw "Filesystem trust blocker changed during final removal." } } Invoke-MinerOpsTransitionCheckpoint ` -Name "before-recovery-cleanup" -Action $CheckpointAction # Recovery is last: it contains the token and exact secret required to # finish safely if cleanup itself is interrupted. Remove-MinerOpsProtectedCredentialArtifact ` -Path $recoveryPath -ExpectedParent $TargetRoot $enrollmentToken = $null $candidateSecret = $null $recovery = $null Write-Host "Existing Agent credential transition completed." Write-Host "Agent ID: $existingAgentId" Write-Host "Delete the downloaded ZIP and extracted setup folder after verification." } catch { if (-not $redemptionAttempted -and $null -ne $recovery) { try { Set-MinerOpsScheduledTaskState ` -Name $ScheduledTaskName ` -TargetRoot $TargetRoot ` -ShouldRun ([bool]$recovery.original_task_running) } catch { Write-Warning "The original Scheduled Task state could not be restored." } } if ($activeVerified) { Write-Warning @" The permanent per-Agent credential is verified in agent-config.json, but task restoration or cleanup did not complete. Keep the protected recovery state and rerun this same script. "@ } elseif ($redemptionAttempted) { Write-Warning @" The Hub transaction may have committed. The Agent task remains stopped. Keep the protected transition artifacts and rerun this same script; it will reuse the exact same transition token and locally generated credential. "@ } throw } } # Dot-sourcing loads the implementation for deterministic and native-Windows # harnesses without touching an Agent. Normal package execution runs it once. if ($MyInvocation.InvocationName -ne ".") { Invoke-MinerOpsAgentCredentialTransition ` -ApproveFilesystemTrustResetAfterReboot:$ApproveFilesystemTrustResetAfterReboot }