Files
minerops/agents/windows/Upgrade-MinerOpsAgentCredential.ps1

3123 lines
116 KiB
PowerShell

#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
}