3123 lines
116 KiB
PowerShell
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
|
|
}
|