diff --git a/agents/windows/Enroll-MinerOpsAgent.ps1 b/agents/windows/Enroll-MinerOpsAgent.ps1 index 7e2668e..b070709 100644 --- a/agents/windows/Enroll-MinerOpsAgent.ps1 +++ b/agents/windows/Enroll-MinerOpsAgent.ps1 @@ -1,6 +1,8 @@ #Requires -RunAsAdministrator [CmdletBinding()] -param() +param( + [switch]$ApproveFilesystemTrustResetAfterReboot +) $ErrorActionPreference = "Stop" @@ -25,34 +27,854 @@ $RequiredFiles = @( "enrollment.json" ) -function Protect-MinerOpsCredentialFile { - param( - [Parameter(Mandatory = $true)] - [string] $Path - ) +function Get-MinerOpsEnrollmentDirectorySids { + return @(@( + "S-1-5-18", + "S-1-5-32-544" + ) | Select-Object -Unique) +} +function New-MinerOpsEnrollmentCredentialAcl { $acl = New-Object System.Security.AccessControl.FileSecurity $acl.SetAccessRuleProtection($true, $false) - - $identities = @( - (New-Object System.Security.Principal.SecurityIdentifier("S-1-5-18")), - (New-Object System.Security.Principal.SecurityIdentifier("S-1-5-32-544")), - [System.Security.Principal.WindowsIdentity]::GetCurrent().User - ) - - foreach ($identity in $identities) { + $acl.SetOwner((New-Object ` + System.Security.Principal.SecurityIdentifier("S-1-5-32-544"))) + foreach ($sidValue in (Get-MinerOpsEnrollmentDirectorySids)) { $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( - $identity, + (New-Object System.Security.Principal.SecurityIdentifier($sidValue)), [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 +} - $acl.AddAccessRule($rule) +function Test-MinerOpsEnrollmentCredentialAcl { + 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-MinerOpsEnrollmentDirectorySids)) { + $expected[$sid] = $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 + ) { return $false } + $expected[$sid] = $true + } + return -not ($expected.Values -contains $false) +} + +function Set-MinerOpsEnrollmentStreamAcl { + param([Parameter(Mandatory)]$Stream, [Parameter(Mandatory)]$Acl) + if ($Stream.PSObject.Methods.Name -contains "SetAccessControl") { + $Stream.SetAccessControl($Acl) + } + else { + [System.IO.FileSystemAclExtensions]::SetAccessControl($Stream, $Acl) + } +} + +function New-MinerOpsEnrollmentProtectedFileCreateNewStream { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)] + [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-MinerOpsEnrollmentFileStreamWithRights { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)] + [System.Security.AccessControl.FileSystemRights]$Rights + ) + + return [IO.FileStream]::new( + $Path, + [IO.FileMode]::Open, + $Rights, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::None + ) +} + +function Get-MinerOpsEnrollmentStreamAcl { + param([Parameter(Mandatory)]$Stream) + if ($Stream.PSObject.Methods.Name -contains "GetAccessControl") { + return $Stream.GetAccessControl() + } + return [System.IO.FileSystemAclExtensions]::GetAccessControl($Stream) +} + +function Assert-MinerOpsEnrollmentSingleLinkStream { + param( + [Parameter(Mandatory)]$Stream, + [Parameter(Mandatory)][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; + } +} +'@ + } + if ([MinerOpsFileIdentityNative]::GetNumberOfLinks( + $Stream.SafeFileHandle + ) -ne 1) { + throw "$Description must have exactly one filesystem link." + } +} + +function Get-MinerOpsEnrollmentTrustBlockerPaths { + param([Parameter(Mandatory)][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-MinerOpsEnrollmentKernelBootEventRecord { + param([Parameter(Mandatory)]$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." } - Set-Acl -LiteralPath $Path -AclObject $acl + $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 + Version = [byte]$EventRecord.Version + BootType = [UInt32]$bootType + } +} + +function Get-MinerOpsEnrollmentBootIdentifier { + try { + $events = @(Get-WinEvent -FilterHashtable @{ + LogName = "System" + ProviderName = "Microsoft-Windows-Kernel-Boot" + Id = 27 + } -ErrorAction Stop) + $fullBootRecordIds = @($events | ForEach-Object { + $record = ConvertFrom-MinerOpsEnrollmentKernelBootEventRecord ` + -EventRecord $_ + if ($record.BootType -eq 0) { [long]$record.RecordId } + } | Sort-Object -Descending) + if ($fullBootRecordIds.Count -eq 0) { + throw "Windows full-kernel-boot event is missing." + } + for ($index = 1; $index -lt $fullBootRecordIds.Count; $index++) { + if ([long]$fullBootRecordIds[$index - 1] -eq + [long]$fullBootRecordIds[$index]) { + throw "Windows full-kernel-boot event is ambiguous." + } + } + return "windows-full-boot-event-v1:{0:x16}" -f ` + [UInt64]$fullBootRecordIds[0] + } + catch { throw "Windows boot identity could not be verified." } +} + +function ConvertFrom-MinerOpsEnrollmentBootIdentifier { + param([Parameter(Mandatory)]$Value) + + if ($Value -isnot [string] -or + $Value -cnotmatch '^windows-full-boot-event-v1:[0-9a-f]{16}$') { + throw "Enrollment filesystem trust blocker boot identity is invalid." + } + $parsed = [UInt64]0 + if (-not [UInt64]::TryParse( + $Value.Substring(27), + [Globalization.NumberStyles]::AllowHexSpecifier, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$parsed + ) -or $parsed -eq 0) { + throw "Enrollment filesystem trust blocker boot identity is invalid." + } + return "windows-full-boot-event-v1:{0:x16}" -f $parsed +} + +function New-MinerOpsEnrollmentTrustBlocker { + param([Parameter(Mandatory)][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 marker is outside the contaminated directory so a handle opened + # inside that directory before repair cannot remove the durable block. + $stream = New-MinerOpsEnrollmentProtectedFileCreateNewStream ` + -Path $path -Acl (New-MinerOpsEnrollmentCredentialAcl) + if (-not (Test-MinerOpsEnrollmentCredentialAcl ` + -Acl (Get-MinerOpsEnrollmentStreamAcl -Stream $stream))) { + throw "Enrollment filesystem trust blocker ACL could not be verified." + } + Assert-MinerOpsEnrollmentSingleLinkStream -Stream $stream ` + -Description "Enrollment 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-MinerOpsEnrollmentBootIdentifier + reset_boot_id = $null + original_task_running = $null + created_at = [DateTime]::UtcNow.ToString("o") + } + $bytes = (New-Object Text.UTF8Encoding($false)).GetBytes( + (($record | ConvertTo-Json -Depth 10) + [Environment]::NewLine) + ) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } + finally { + if ($null -ne $stream) { $stream.Dispose() } + } + return $path +} + +function Open-MinerOpsEnrollmentCredentialFileCreateNew { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$ExpectedParent + ) + + [void](Assert-MinerOpsEnrollmentDirectory -Path $ExpectedParent) + $fullPath = [IO.Path]::GetFullPath($Path) + $actualParent = [IO.Directory]::GetParent($fullPath).FullName.TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + $expected = [IO.Path]::GetFullPath($ExpectedParent).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + if (-not $actualParent.Equals( + $expected, [StringComparison]::OrdinalIgnoreCase + )) { throw "Enrollment credential path is outside the Agent directory." } + + $stream = $null + $createdByThisCall = $false + try { + $stream = New-MinerOpsEnrollmentProtectedFileCreateNewStream ` + -Path $fullPath -Acl (New-MinerOpsEnrollmentCredentialAcl) + $createdByThisCall = $true + if (-not (Test-MinerOpsEnrollmentCredentialAcl ` + -Acl (Get-MinerOpsEnrollmentStreamAcl -Stream $stream))) { + throw "Enrollment credential file ACL could not be verified." + } + Assert-MinerOpsEnrollmentSingleLinkStream -Stream $stream ` + -Description "Enrollment credential file" + return $stream + } + catch { + if ($null -ne $stream) { + $stream.Dispose() + $stream = $null + } + if ($createdByThisCall -and + (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + Remove-Item -LiteralPath $fullPath -Force ` + -ErrorAction SilentlyContinue + } + throw + } +} + +function Write-MinerOpsEnrollmentCredentialJson { + param( + [Parameter(Mandatory)]$Stream, + [Parameter(Mandatory)][string]$Json + ) + + Assert-MinerOpsEnrollmentSingleLinkStream -Stream $Stream ` + -Description "Enrollment credential file" + $bytes = (New-Object Text.UTF8Encoding($false)).GetBytes( + $Json + [Environment]::NewLine + ) + $Stream.Write($bytes, 0, $bytes.Length) + $Stream.Flush($true) + Assert-MinerOpsEnrollmentSingleLinkStream -Stream $Stream ` + -Description "Enrollment credential file" +} + +function Assert-MinerOpsEnrollmentCredentialFile { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$ExpectedParent + ) + + [void](Assert-MinerOpsEnrollmentDirectory -Path $ExpectedParent) + $fullPath = [IO.Path]::GetFullPath($Path) + $actualParent = [IO.Directory]::GetParent($fullPath).FullName.TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + $expected = [IO.Path]::GetFullPath($ExpectedParent).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + if (-not $actualParent.Equals( + $expected, [StringComparison]::OrdinalIgnoreCase + )) { throw "Enrollment credential path 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) + ) { throw "Enrollment credential path is not a regular single-link file." } + + $stream = $null + try { + $stream = Open-MinerOpsEnrollmentFileStreamWithRights ` + -Path $fullPath ` + -Rights ([System.Security.AccessControl.FileSystemRights]( + [System.Security.AccessControl.FileSystemRights]::ReadPermissions -bor + [System.Security.AccessControl.FileSystemRights]::Synchronize + )) + Assert-MinerOpsEnrollmentSingleLinkStream -Stream $stream ` + -Description "Enrollment credential file" + if (-not (Test-MinerOpsEnrollmentCredentialAcl ` + -Acl (Get-MinerOpsEnrollmentStreamAcl -Stream $stream))) { + throw "Enrollment credential file protection could not be verified." + } + } + finally { + if ($null -ne $stream) { $stream.Dispose() } + } +} + +function New-MinerOpsEnrollmentDirectoryAcl { + $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 ($sidValue in (Get-MinerOpsEnrollmentDirectorySids)) { + $identity = New-Object System.Security.Principal.SecurityIdentifier( + $sidValue + ) + $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) + } + $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 Assert-MinerOpsEnrollmentNoReparsePath { + param([Parameter(Mandatory = $true)][string]$Path) + + $fullPath = [IO.Path]::GetFullPath($Path) + $root = [IO.Path]::GetPathRoot($fullPath) + if (-not $root) { throw "Agent 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 directory path root is a reparse point or non-directory component." + } + $relative = $fullPath.Substring($root.Length) + foreach ($segment in @($relative.Split( + @([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar), + [StringSplitOptions]::RemoveEmptyEntries + ))) { + $current = Join-Path $current $segment + $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop + if ( + -not $item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) + ) { + throw "Agent directory path contains a reparse point or non-directory component." + } + } + return $fullPath +} + +function Assert-MinerOpsEnrollmentTrustedAncestors { + 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 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) + foreach ($segment in @($parent.Substring($root.Length).Split( + @([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar), + [StringSplitOptions]::RemoveEmptyEntries + ))) { + $current = Join-Path $current $segment + $components.Add($current) + } + $trusted = @{} + foreach ($sid in @( + "S-1-5-18", "S-1-5-32-544", + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", + [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + )) { $trusted[$sid] = $true } + $dangerous = [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 directory ancestor is not trusted." + } + $acl = Get-Acl -LiteralPath $component -ErrorAction Stop + try { + $owner = $acl.GetOwner( + [System.Security.Principal.SecurityIdentifier] + ).Value + } + catch { throw "Agent directory ancestor owner could not be verified." } + if (-not $trusted.ContainsKey($owner)) { + throw "Agent directory ancestor has an untrusted owner." + } + foreach ($rule in @($acl.GetAccessRules( + $true, $true, [System.Security.Principal.SecurityIdentifier] + ))) { + if ( + $rule.AccessControlType -eq + [System.Security.AccessControl.AccessControlType]::Allow -and + -not $trusted.ContainsKey($rule.IdentityReference.Value) -and + ($rule.PropagationFlags -band + [System.Security.AccessControl.PropagationFlags]::InheritOnly) -eq 0 -and + ($rule.FileSystemRights -band $dangerous) -ne 0 + ) { + throw "Agent directory ancestor permits untrusted namespace replacement." + } + } + } +} + +function Open-MinerOpsEnrollmentDirectoryLock { + 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); +} +'@ + } + $handle = [MinerOpsDirectoryLockNative]::CreateFile( + [IO.Path]::GetFullPath($Path), 0, 3, [IntPtr]::Zero, 3, + 0x02200000, [IntPtr]::Zero + ) + if ($handle.IsInvalid) { + $code = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + $handle.Dispose() + throw (New-Object ComponentModel.Win32Exception( + $code, "Agent directory could not be locked against replacement." + )) + } + return $handle +} + +function Test-MinerOpsEnrollmentDirectoryAcl { + param([Parameter(Mandatory = $true)]$Acl) + + if (-not $Acl.AreAccessRulesProtected) { return $false } + $trusted = @{} + foreach ($sid in (Get-MinerOpsEnrollmentDirectorySids)) { + $trusted[$sid] = $false + } + $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 + ) + foreach ($rule in @($Acl.GetAccessRules( + $true, $false, [System.Security.Principal.SecurityIdentifier] + ))) { + $sid = $rule.IdentityReference.Value + if ( + $rule.IsInherited -or + $rule.AccessControlType -ne + [System.Security.AccessControl.AccessControlType]::Allow + ) { 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 + } + elseif ($sid -eq "S-1-5-32-545") { + 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 + } + else { return $false } + } + return $usersRead -and -not ($trusted.Values -contains $false) +} + +function Assert-MinerOpsEnrollmentDirectory { + param([Parameter(Mandatory = $true)][string]$Path) + + $fullPath = Assert-MinerOpsEnrollmentNoReparsePath -Path $Path + [void](Assert-MinerOpsEnrollmentTrustedAncestors -Path $fullPath) + if (-not (Test-MinerOpsEnrollmentDirectoryAcl ` + -Acl (Get-Acl -LiteralPath $fullPath -ErrorAction Stop))) { + throw "Agent directory owner or DACL is not trusted." + } + return $fullPath +} + +function Protect-MinerOpsEnrollmentExistingConfig { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedParent + ) + + if (-not (Test-Path -LiteralPath $Path)) { return } + [void](Assert-MinerOpsEnrollmentDirectory -Path $ExpectedParent) + $fullPath = [IO.Path]::GetFullPath($Path) + $parent = [IO.Directory]::GetParent($fullPath).FullName.TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + $expected = [IO.Path]::GetFullPath($ExpectedParent).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + $item = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop + if ( + -not $parent.Equals($expected, [StringComparison]::OrdinalIgnoreCase) -or + $item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) + ) { + throw "Existing Agent config is not a regular file in the Agent directory." + } + $stream = $null + try { + $stream = Open-MinerOpsEnrollmentFileStreamWithRights ` + -Path $fullPath ` + -Rights ([System.Security.AccessControl.FileSystemRights]( + [System.Security.AccessControl.FileSystemRights]::ReadPermissions -bor + [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [System.Security.AccessControl.FileSystemRights]::TakeOwnership -bor + [System.Security.AccessControl.FileSystemRights]::Synchronize + )) + Assert-MinerOpsEnrollmentSingleLinkStream -Stream $stream ` + -Description "Existing Agent config" + Set-MinerOpsEnrollmentStreamAcl -Stream $stream ` + -Acl (New-MinerOpsEnrollmentCredentialAcl) + if (-not (Test-MinerOpsEnrollmentCredentialAcl ` + -Acl (Get-MinerOpsEnrollmentStreamAcl -Stream $stream))) { + throw "Existing Agent config ACL could not be verified." + } + } + finally { + if ($null -ne $stream) { $stream.Dispose() } + } +} + +function Initialize-MinerOpsEnrollmentDirectory { + param( + [Parameter(Mandatory = $true)][string]$Path, + [switch]$ApproveFilesystemTrustResetAfterReboot + ) + + $requestedPath = [IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $requestedPath)) { + $parent = [IO.Directory]::GetParent($requestedPath).FullName + [void](Assert-MinerOpsEnrollmentNoReparsePath -Path $parent) + [void](Assert-MinerOpsEnrollmentTrustedAncestors -Path $requestedPath) + $staging = Join-Path $parent ( + ".minerops-agent-enroll-{0}" -f [guid]::NewGuid().ToString("N") + ) + $stagingCreated = $false + try { + (New-Object IO.DirectoryInfo($staging)).Create( + (New-MinerOpsEnrollmentDirectoryAcl) + ) + $stagingCreated = $true + if (-not (Test-MinerOpsEnrollmentDirectoryAcl ` + -Acl (Get-Acl -LiteralPath $staging -ErrorAction Stop))) { + throw "Staged Agent directory DACL could not be verified." + } + [void](Assert-MinerOpsEnrollmentTrustedAncestors -Path $requestedPath) + [IO.Directory]::Move($staging, $requestedPath) + $stagingCreated = $false + } + finally { + if ($stagingCreated -and (Test-Path -LiteralPath $staging)) { + Remove-Item -LiteralPath $staging -Force -ErrorAction SilentlyContinue + } + } + } + $fullPath = Assert-MinerOpsEnrollmentNoReparsePath -Path $requestedPath + [void](Assert-MinerOpsEnrollmentTrustedAncestors -Path $fullPath) + $wasTrusted = Test-MinerOpsEnrollmentDirectoryAcl ` + -Acl (Get-Acl -LiteralPath $fullPath -ErrorAction Stop) + if (-not $wasTrusted) { + $directoryLock = $null + $trustBlockerPath = New-MinerOpsEnrollmentTrustBlocker ` + -TargetRoot $fullPath + try { + $directoryLock = Open-MinerOpsEnrollmentDirectoryLock -Path $fullPath + [void](Assert-MinerOpsEnrollmentNoReparsePath -Path $fullPath) + [void](Assert-MinerOpsEnrollmentTrustedAncestors -Path $fullPath) + Set-Acl -LiteralPath $fullPath ` + -AclObject (New-MinerOpsEnrollmentDirectoryAcl) + [void](Assert-MinerOpsEnrollmentDirectory -Path $fullPath) + } + finally { + if ($null -ne $directoryLock) { $directoryLock.Dispose() } + } + throw @" +The existing Agent directory was untrusted. It was hardened for containment, +but enrollment is blocked for this and every ordinary rerun. Reboot and follow +the documented protected-recreation trust-reset procedure. No token was +redeemed and no permanent credential was generated or written. +"@ + } + [void](Assert-MinerOpsEnrollmentDirectory -Path $fullPath) + $trustBlockers = @( + Get-MinerOpsEnrollmentTrustBlockerPaths -Path $fullPath + ) + if ($trustBlockers.Count -gt 0) { + throw @" +Enrollment cannot clear an existing filesystem trust blocker, including with +-ApproveFilesystemTrustResetAfterReboot. Preserve evidence and use the +documented protected-recreation incident procedure. Only the authoritative +existing-Agent credential-transition parent has in-script blocker-deletion +authority after its complete convergence checks. +"@ + } + Protect-MinerOpsEnrollmentExistingConfig ` + -Path (Join-Path $fullPath "agent-config.json") ` + -ExpectedParent $fullPath + if (@(Get-ChildItem -LiteralPath $fullPath ` + -Filter "minerops-credential-transition.blocked*.state" ` + -Force -ErrorAction Stop).Count -gt 0) { + throw "Credential transition is blocked pending operator review." + } + return $fullPath } function ConvertFrom-MinerOpsSecureString { @@ -83,29 +905,16 @@ foreach ($name in $RequiredFiles) { } } -if (Test-Path -LiteralPath $ConfigPath) { - throw @" -C:\MinerOpsAgent\agent-config.json already exists. -This enrollment bootstrapper is for a new Agent identity and will not overwrite -an existing identity. Use the normal MinerOps upgrade procedure instead. -"@ -} - -foreach ($recoveryPath in @($PendingConfigPath, $RecoveryConfigPath)) { - if (Test-Path -LiteralPath $recoveryPath) { - throw @" -A previous enrollment recovery file exists at: -$recoveryPath - -Do not create or redeem another enrollment until that recovery state has been -reviewed. -"@ - } -} - $enrollment = Get-Content -LiteralPath $EnrollmentPath -Raw | ConvertFrom-Json +if ([string]$enrollment.workflow -ne "new_agent") { + throw @" +This package is not authorized for a new Agent enrollment. +Use the bootstrap script named by INSTALL.txt. +"@ +} + $HubUrl = ([string] $enrollment.hub_url).TrimEnd("/") $EnrollmentToken = ([string] $enrollment.token).Trim() @@ -129,6 +938,33 @@ if ( throw "enrollment.json contains an invalid enrollment token." } +$Target = Initialize-MinerOpsEnrollmentDirectory ` + -Path $Target ` + -ApproveFilesystemTrustResetAfterReboot:$ApproveFilesystemTrustResetAfterReboot +$ConfigPath = Join-Path $Target "agent-config.json" +$PendingConfigPath = Join-Path $Target "agent-config.json.pending" +$RecoveryConfigPath = Join-Path $Target "agent-config.recovery.json" + +if (Test-Path -LiteralPath $ConfigPath) { + throw @" +C:\MinerOpsAgent\agent-config.json already exists. +This enrollment bootstrapper is for a new Agent identity and will not overwrite +an existing identity. Use the normal MinerOps upgrade procedure instead. +"@ +} + +foreach ($recoveryPath in @($PendingConfigPath, $RecoveryConfigPath)) { + if (Test-Path -LiteralPath $recoveryPath) { + throw @" +A previous enrollment recovery file exists at: +$recoveryPath + +Do not create or redeem another enrollment until that recovery state has been +reviewed. +"@ + } +} + Write-Host "" Write-Host "MinerOps Windows Agent enrollment" Write-Host "Hub: $HubUrl" @@ -151,17 +987,22 @@ if ($Api3Account) { $Redeemed = $false $ConfigPersisted = $false +$pendingStream = $null +$recoveryStream = $null +$PendingCreatedByThisInvocation = $false +$RecoveryCreatedByThisInvocation = $false try { - New-Item -ItemType Directory -Force -Path $Target | Out-Null + [void](Assert-MinerOpsEnrollmentDirectory -Path $Target) # Create protected empty files BEFORE token redemption. If persistence is # not possible, fail before consuming the single-use token. - [IO.File]::WriteAllText($PendingConfigPath, "") - Protect-MinerOpsCredentialFile -Path $PendingConfigPath - - [IO.File]::WriteAllText($RecoveryConfigPath, "") - Protect-MinerOpsCredentialFile -Path $RecoveryConfigPath + $pendingStream = Open-MinerOpsEnrollmentCredentialFileCreateNew ` + -Path $PendingConfigPath -ExpectedParent $Target + $PendingCreatedByThisInvocation = $true + $recoveryStream = Open-MinerOpsEnrollmentCredentialFileCreateNew ` + -Path $RecoveryConfigPath -ExpectedParent $Target + $RecoveryCreatedByThisInvocation = $true $requestBody = @{ token = $EnrollmentToken @@ -231,29 +1072,28 @@ try { } $configJson = $config | ConvertTo-Json -Depth 20 - $utf8NoBom = New-Object System.Text.UTF8Encoding($false) - # Write a protected recovery copy first. If anything after redemption # fails, the permanent Agent credential is not lost with the consumed token. - [IO.File]::WriteAllText( - $RecoveryConfigPath, - $configJson + [Environment]::NewLine, - $utf8NoBom - ) + Write-MinerOpsEnrollmentCredentialJson ` + -Stream $recoveryStream -Json $configJson + Write-MinerOpsEnrollmentCredentialJson ` + -Stream $pendingStream -Json $configJson + $recoveryStream.Dispose() + $recoveryStream = $null + $pendingStream.Dispose() + $pendingStream = $null - [IO.File]::WriteAllText( - $PendingConfigPath, - $configJson + [Environment]::NewLine, - $utf8NoBom - ) - - Move-Item ` - -LiteralPath $PendingConfigPath ` - -Destination $ConfigPath + [void](Assert-MinerOpsEnrollmentDirectory -Path $Target) + [IO.File]::Move($PendingConfigPath, $ConfigPath) + $PendingCreatedByThisInvocation = $false + Assert-MinerOpsEnrollmentCredentialFile ` + -Path $ConfigPath -ExpectedParent $Target $ConfigPersisted = $true + [void](Assert-MinerOpsEnrollmentDirectory -Path $Target) Remove-Item -LiteralPath $RecoveryConfigPath -Force + $RecoveryCreatedByThisInvocation = $false # The extracted bootstrap token is no longer needed once permanent # credentials have been persisted locally. @@ -285,14 +1125,23 @@ try { Write-Host "Delete the downloaded ZIP and extracted setup folder after verification." } catch { + if ($null -ne $pendingStream) { + $pendingStream.Dispose() + $pendingStream = $null + } + if ($null -ne $recoveryStream) { + $recoveryStream.Dispose() + $recoveryStream = $null + } if (-not $Redeemed) { - foreach ($temporaryPath in @( - $PendingConfigPath, - $RecoveryConfigPath + foreach ($temporary in @( + @{ Path = $PendingConfigPath; Created = $PendingCreatedByThisInvocation }, + @{ Path = $RecoveryConfigPath; Created = $RecoveryCreatedByThisInvocation } )) { - if (Test-Path -LiteralPath $temporaryPath) { + if ([bool]$temporary.Created -and + (Test-Path -LiteralPath ([string]$temporary.Path))) { Remove-Item ` - -LiteralPath $temporaryPath ` + -LiteralPath ([string]$temporary.Path) ` -Force ` -ErrorAction SilentlyContinue } diff --git a/agents/windows/MinerOpsAgentGUI.ps1 b/agents/windows/MinerOpsAgentGUI.ps1 index 7feb4ba..f026e10 100644 --- a/agents/windows/MinerOpsAgentGUI.ps1 +++ b/agents/windows/MinerOpsAgentGUI.ps1 @@ -204,9 +204,11 @@ $testButton.Add_Click({ } $hub = [string]$config.hub_url + $agentId = [string]$config.agent_id $secret = [string]$config.agent_secret $headers = @{ + "X-MinerOps-Agent-ID" = $agentId "X-MinerOps-Agent-Secret" = $secret } diff --git a/agents/windows/README.md b/agents/windows/README.md index 9664599..e63fd5e 100644 --- a/agents/windows/README.md +++ b/agents/windows/README.md @@ -74,9 +74,122 @@ credentials in screenshots, chat, tickets, logs, or source control. ### Existing Agent upgrade -For an already enrolled/configured Agent, do not run the enrollment -bootstrapper. Copy the new canonical Agent files to a temporary directory and -run install.ps1 from an elevated Windows PowerShell session. +An existing legacy Agent must receive a per-Agent credential before the global +shared-secret compatibility path can be retired for its identity. In Agent +Management, use **Upgrade credential** on that Agent. Download the resulting +Windows package, transfer it only to that Agent computer, and run: + + .\Upgrade-MinerOpsAgentCredential.ps1 + +The authorization is bound server-side to the existing Agent ID and its +current Site and Agent Zone. The script validates the local Agent ID, upgrades +the canonical runtime, stops the main Agent task, generates the permanent +secret locally, and recoverably replaces only the credential and durable routing +fields in `agent-config.json` using protected same-directory pending and prior +names. Existing API3 credentials, polling settings, +network ranges, state, logs, and backups are preserved. Before any secret is +written, the script creates and verifies a protected recovery file containing +the original Scheduled Task state, exact transition token, and exact locally +generated credential. The Hub accepts an ambiguous retry only for that same +token and credential. + +The Agent directory is a filesystem security boundary. The transition rejects +junctions/reparse points, verifies trusted ancestors, denies standard users +create/delete/rename rights, and revalidates the boundary before each rename. +If an existing Agent directory is discovered insecure, the script first writes +`C:\MinerOpsAgent.minerops-filesystem-trust.blocked-.state` under the trusted +parent, then hardens the directory only to contain the incident. The blocker is +outside the contaminated namespace because changing a DACL does not revoke +file or directory handles opened before repair. The same process never redeems +a token, creates or activates a permanent credential, runs the installer, or +removes that blocker. Ordinary second, third, and later invocations remain +blocked even when every current ACL looks correct. + +Recovery, pending, replacement remnants, and late-arriving artifacts are +non-authoritative while the external blocker exists. Known transition state is +quarantined without parsing. A correct filename, syntax, or ACL cannot promote +it on a later run. Invalidate any possibly exposed authorization and retain or +remove evidence only under the approved incident procedure. + +### Filesystem trust-epoch recovery + +Use this procedure only after confirming the blocker was created by the +containment event and preserving required evidence: + +1. Do not delete or edit the external blocker. Do not run an ordinary retry. +2. Use **Restart** to perform a full Windows kernel boot. Shutdown with Fast + Startup is not sufficient: Microsoft documents Fast Startup as hibernating + and restoring kernel session 0. The script identifies the kernel epoch by + the Event Log record anchor of the newest + `Microsoft-Windows-Kernel-Boot` event 27 whose `BootType` is exactly `0`. + Provider, event ID, record ID, and supported event version come from typed + EventRecord properties; namespace-aware XML selection reads only the + `BootType` payload. The new full-boot record ID must be strictly greater + than the stored record ID. Equality, an older/restored record, log clearing + or numbering reset, missing history, duplication, malformed XML, and an + unsupported provider schema all fail closed and require safe operator + intervention. There is no timestamp fallback. The anchor is unchanged by + clock or timezone changes, sleep, hibernation, and Fast Startup. See + Microsoft's [system power-state documentation](https://learn.microsoft.com/windows/win32/power/system-power-states) + and [Fast Startup support guidance](https://learn.microsoft.com/troubleshoot/windows-client/setup-upgrade-and-drivers/fast-startup-causes-system-hibernation-shutdown-fail). +3. For a credential transition, use a fresh transition package if any recovery, + pending, remnant, quarantine, or internal blocker existed. Remove those + artifacts only under the approved incident procedure after invalidating the + old authorization. +4. From the reviewed package, run: + + .\Upgrade-MinerOpsAgentCredential.ps1 ` + -ApproveFilesystemTrustResetAfterReboot + + The script verifies that Windows boot identity changed, rescans the protected + namespace, positively verifies and stops the expected Scheduled Task, and asks the + installer to replace managed runtime without backing up or restoring any + pre-reset runtime. The installer leaves both the Agent task and external + blocker untouched after publication; it has no blocker-deletion path. Before + its first fallible stop/install step, the parent durably records the strictly + validated original task intent as `reset-authorized`; after installer + success it advances the blocker to `runtime-published`. It then verifies the + config against the package/server identity, commits the credential, and + restores the recorded task intent. Only the parent removes the blocker, + after active config and final task state have both been positively verified + and non-recovery transition cleanup has succeeded. Protected recovery stays + until after blocker deletion so an interrupted final cleanup remains safe. +5. For a genuinely new enrollment, preserve evidence and use the approved + protected-recreation incident procedure to remove the contaminated namespace + and its external blocker, then let `Enroll-MinerOpsAgent.ps1` stage a new + protected directory. Enrollment has no in-script blocker-deletion path; its + explicit compatibility switch cannot override a retained blocker. + +The explicit switch without a strictly newer canonical full-kernel-boot anchor +fails. +Any suspicious transition artifact, failed runtime publication, failed +credential convergence, invalid final task, or interrupted cleanup retains the +blocker. Runtime publication alone never authorizes deletion. Protected +directory recreation is the alternative when the retained namespace cannot be +verified. + +If the script is interrupted after the Hub commits, rerun the same extracted +package without deleting or editing `agent-credential-transition.recovery.json` +or `agent-config.json.pending`. A protected, matching pending config is resumed; +an insecure, malformed, or mismatched artifact fails closed. The original task +intent is durable: an Agent which was running before the first attempt ends +running, while an intentionally stopped Agent remains stopped. Success is not +reported and transition artifacts are not removed until both the active config +and expected final task state are verified. The verifier requires exactly one +enabled root `MinerOpsAgent` task, the SYSTEM service-account/highest principal, +the installed Windows PowerShell action and service-script arguments, and an +exact final state of `Running` for running intent or `Ready` for stopped intent. +Missing, query failure, access denied, malformed, multiple, disabled, queued, +unknown, wrong-action, and wrong-principal results all fail closed and retain +recovery. + +After a successful transition, the existing Agent ID, Site, and Zone are +unchanged. Explicit Agent ID plus the new per-Agent secret is required, and the +legacy shared secret is permanently disabled for that identity. + +For later code-only upgrades of an Agent that already has a per-Agent +credential, copy the new canonical Agent files to a temporary directory and run +install.ps1 from an elevated Windows PowerShell session. The installer copies only managed runtime code to C:\MinerOpsAgent and preserves agent-config.json, agent-state.json, logs\, backups, probe output, @@ -90,6 +203,14 @@ again. A previously stopped Agent remains stopped. Before replacing runtime files, the installer copies them to a timestamped backup under C:\MinerOpsAgent\backups. +Runtime sources, installed destinations, active config, and rollback copies are +rejected when they are reparse points or have more than one hard link. A normal +trusted upgrade creates a fresh random backup directory and never adopts an +existing backup destination. During an explicit post-reboot trust reset, old +runtime is never copied into backup or restored; fresh package content replaces +it while the external blocker remains, and the Agent task stays stopped for +credential verification. + A failed deployment restores managed files, removes tasks created by that failed attempt, and restores the main Agent's prior running state. diff --git a/agents/windows/Upgrade-MinerOpsAgentCredential.ps1 b/agents/windows/Upgrade-MinerOpsAgentCredential.ps1 new file mode 100644 index 0000000..5bc7bbb --- /dev/null +++ b/agents/windows/Upgrade-MinerOpsAgentCredential.ps1 @@ -0,0 +1,3122 @@ +#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 +} diff --git a/agents/windows/install.ps1 b/agents/windows/install.ps1 index 4025bdc..221bb0d 100644 --- a/agents/windows/install.ps1 +++ b/agents/windows/install.ps1 @@ -1,4 +1,9 @@ #Requires -RunAsAdministrator +[CmdletBinding()] +param( + [switch]$ApproveFilesystemTrustResetAfterReboot +) + $ErrorActionPreference = "Stop" $Target = "C:\MinerOpsAgent" @@ -13,10 +18,939 @@ $RuntimeFiles = @( "VERSION" ) +function Get-MinerOpsInstallerDirectorySids { + return @(@( + "S-1-5-18", + "S-1-5-32-544" + ) | Select-Object -Unique) +} + +function New-MinerOpsInstallerDirectoryAcl { + $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 ($sidValue in (Get-MinerOpsInstallerDirectorySids)) { + $sid = New-Object System.Security.Principal.SecurityIdentifier($sidValue) + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $sid, + [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) + } + $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-MinerOpsInstallerProtectedFileAcl { + $acl = New-Object System.Security.AccessControl.FileSecurity + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner((New-Object ` + System.Security.Principal.SecurityIdentifier("S-1-5-32-544"))) + foreach ($sidValue in (Get-MinerOpsInstallerDirectorySids)) { + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + (New-Object System.Security.Principal.SecurityIdentifier($sidValue)), + [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-MinerOpsInstallerRuntimeFileAcl { + $acl = New-Object System.Security.AccessControl.FileSecurity + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner((New-Object ` + System.Security.Principal.SecurityIdentifier("S-1-5-32-544"))) + foreach ($sidValue in (Get-MinerOpsInstallerDirectorySids)) { + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + (New-Object System.Security.Principal.SecurityIdentifier($sidValue)), + [System.Security.AccessControl.FileSystemRights]::FullControl, + [System.Security.AccessControl.InheritanceFlags]::None, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow + ) + [void]$acl.AddAccessRule($rule) + } + $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]::None, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow + ) + [void]$acl.AddAccessRule($readRule) + return $acl +} + +function Test-MinerOpsInstallerRuntimeFileAcl { + param([Parameter(Mandatory)]$Acl) + + $expected = @{ + "S-1-5-18" = "full" + "S-1-5-32-544" = "full" + "S-1-5-32-545" = "read" + } + $seen = @{} + try { + $owner = $Acl.GetOwner( + [System.Security.Principal.SecurityIdentifier] + ).Value + } + catch { return $false } + $trustedOwners = @( + "S-1-5-18", + "S-1-5-32-544", + [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + ) | Select-Object -Unique + if ($owner -notin $trustedOwners) { return $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.AccessControlType -ne + [System.Security.AccessControl.AccessControlType]::Allow -or + $rule.InheritanceFlags -ne + [System.Security.AccessControl.InheritanceFlags]::None -or + $rule.PropagationFlags -ne + [System.Security.AccessControl.PropagationFlags]::None + ) { return $false } + if ($expected[$sid] -eq "full" -and + ($rule.FileSystemRights -band + [System.Security.AccessControl.FileSystemRights]::FullControl) -ne + [System.Security.AccessControl.FileSystemRights]::FullControl) { + return $false + } + if ($expected[$sid] -eq "read") { + $writeLike = [System.Security.AccessControl.FileSystemRights]( + [System.Security.AccessControl.FileSystemRights]::Write -bor + [System.Security.AccessControl.FileSystemRights]::Delete -bor + [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [System.Security.AccessControl.FileSystemRights]::TakeOwnership + ) + if ( + ($rule.FileSystemRights -band $writeLike) -ne 0 -or + ($rule.FileSystemRights -band + [System.Security.AccessControl.FileSystemRights]::ReadAndExecute) -ne + [System.Security.AccessControl.FileSystemRights]::ReadAndExecute + ) { return $false } + } + $seen[$sid] = $true + } + return $seen.Count -eq $expected.Count +} + +function Protect-MinerOpsInstallerRuntimeFile { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Description + ) + + Assert-MinerOpsInstallerSingleLinkPath ` + -Path $Path -Description $Description + Set-Acl -LiteralPath $Path ` + -AclObject (New-MinerOpsInstallerRuntimeFileAcl) ` + -ErrorAction Stop + if (-not (Test-MinerOpsInstallerRuntimeFileAcl ` + -Acl (Get-Acl -LiteralPath $Path -ErrorAction Stop))) { + throw "$Description ACL could not be verified." + } + Assert-MinerOpsInstallerSingleLinkPath ` + -Path $Path -Description $Description +} + +function Assert-MinerOpsInstallerTrustedRuntimeFile { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Description + ) + + Assert-MinerOpsInstallerSingleLinkPath ` + -Path $Path -Description $Description + if (-not (Test-MinerOpsInstallerRuntimeFileAcl ` + -Acl (Get-Acl -LiteralPath $Path -ErrorAction Stop))) { + throw "$Description owner or DACL is not trusted rollback provenance." + } +} + +function Test-MinerOpsInstallerProtectedFileAcl { + param([Parameter(Mandatory)]$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-MinerOpsInstallerDirectorySids)) { + $expected[$sid] = $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 + ) { return $false } + $expected[$sid] = $true + } + return -not ($expected.Values -contains $false) +} + +function Set-MinerOpsInstallerStreamAcl { + param([Parameter(Mandatory)]$Stream, [Parameter(Mandatory)]$Acl) + if ($Stream.PSObject.Methods.Name -contains "SetAccessControl") { + $Stream.SetAccessControl($Acl) + } + else { + [System.IO.FileSystemAclExtensions]::SetAccessControl($Stream, $Acl) + } +} + +function New-MinerOpsInstallerProtectedFileCreateNewStream { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)] + [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-MinerOpsInstallerFileStreamWithRights { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)] + [System.Security.AccessControl.FileSystemRights]$Rights + ) + + return [IO.FileStream]::new( + $Path, + [IO.FileMode]::Open, + $Rights, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::None + ) +} + +function Get-MinerOpsInstallerStreamAcl { + param([Parameter(Mandatory)]$Stream) + if ($Stream.PSObject.Methods.Name -contains "GetAccessControl") { + return $Stream.GetAccessControl() + } + return [System.IO.FileSystemAclExtensions]::GetAccessControl($Stream) +} + +function Assert-MinerOpsInstallerSingleLinkStream { + param( + [Parameter(Mandatory)]$Stream, + [Parameter(Mandatory)][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; + } +} +'@ + } + if ([MinerOpsFileIdentityNative]::GetNumberOfLinks( + $Stream.SafeFileHandle + ) -ne 1) { + throw "$Description must have exactly one filesystem link." + } +} + +function Assert-MinerOpsInstallerSingleLinkPath { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Description + ) + + $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 "$Description is not a regular single-link file." + } + $stream = $null + try { + $stream = [IO.File]::Open( + [IO.Path]::GetFullPath($Path), + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + Assert-MinerOpsInstallerSingleLinkStream ` + -Stream $stream -Description $Description + } + finally { + if ($null -ne $stream) { $stream.Dispose() } + } +} + +function Get-MinerOpsInstallerTrustBlockerPaths { + param([Parameter(Mandatory)][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-MinerOpsInstallerKernelBootEventRecord { + param([Parameter(Mandatory)]$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 + Version = [byte]$EventRecord.Version + BootType = [UInt32]$bootType + } +} + +function Get-MinerOpsInstallerBootIdentifier { + try { + $events = @(Get-WinEvent -FilterHashtable @{ + LogName = "System" + ProviderName = "Microsoft-Windows-Kernel-Boot" + Id = 27 + } -ErrorAction Stop) + $fullBootRecordIds = @($events | ForEach-Object { + $record = ConvertFrom-MinerOpsInstallerKernelBootEventRecord ` + -EventRecord $_ + if ($record.BootType -eq 0) { [long]$record.RecordId } + } | Sort-Object -Descending) + if ($fullBootRecordIds.Count -eq 0) { + throw "Windows full-kernel-boot event is missing." + } + for ($index = 1; $index -lt $fullBootRecordIds.Count; $index++) { + if ([long]$fullBootRecordIds[$index - 1] -eq + [long]$fullBootRecordIds[$index]) { + throw "Windows full-kernel-boot event is ambiguous." + } + } + return "windows-full-boot-event-v1:{0:x16}" -f ` + [UInt64]$fullBootRecordIds[0] + } + catch { throw "Windows boot identity could not be verified." } +} + +function New-MinerOpsInstallerTrustBlocker { + param([Parameter(Mandatory)][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 { + # This external sibling is durable even if a pre-repair handle can + # still mutate children of the contaminated Agent directory. + $stream = New-MinerOpsInstallerProtectedFileCreateNewStream ` + -Path $path -Acl (New-MinerOpsInstallerProtectedFileAcl) + if (-not (Test-MinerOpsInstallerProtectedFileAcl ` + -Acl (Get-MinerOpsInstallerStreamAcl -Stream $stream))) { + throw "Installer filesystem trust blocker ACL could not be verified." + } + Assert-MinerOpsInstallerSingleLinkStream -Stream $stream ` + -Description "Installer 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-MinerOpsInstallerBootIdentifier + reset_boot_id = $null + original_task_running = $null + created_at = [DateTime]::UtcNow.ToString("o") + } + $bytes = (New-Object Text.UTF8Encoding($false)).GetBytes( + (($record | ConvertTo-Json -Depth 10) + [Environment]::NewLine) + ) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } + finally { + if ($null -ne $stream) { $stream.Dispose() } + } + return $path +} + +function Assert-MinerOpsInstallerNoReparsePath { + param([Parameter(Mandatory)][string]$Path) + + $fullPath = [IO.Path]::GetFullPath($Path) + $root = [IO.Path]::GetPathRoot($fullPath) + if (-not $root) { throw "Agent 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 directory path root is a reparse point or non-directory component." + } + $relative = $fullPath.Substring($root.Length) + foreach ($segment in @($relative.Split( + @([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar), + [StringSplitOptions]::RemoveEmptyEntries + ))) { + $current = Join-Path $current $segment + $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop + if ( + -not $item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) + ) { + throw "Agent directory path contains a reparse point or non-directory component." + } + } + return $fullPath +} + +function Assert-MinerOpsInstallerTrustedAncestors { + param([Parameter(Mandatory)][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 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) + foreach ($segment in @($parent.Substring($root.Length).Split( + @([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar), + [StringSplitOptions]::RemoveEmptyEntries + ))) { + $current = Join-Path $current $segment + $components.Add($current) + } + $trusted = @{} + foreach ($sid in @( + "S-1-5-18", + "S-1-5-32-544", + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", + [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + )) { $trusted[$sid] = $true } + $dangerous = [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 directory ancestor is not trusted." } + $acl = Get-Acl -LiteralPath $component -ErrorAction Stop + try { + $owner = $acl.GetOwner( + [System.Security.Principal.SecurityIdentifier] + ).Value + } + catch { throw "Agent directory ancestor owner could not be verified." } + if (-not $trusted.ContainsKey($owner)) { + throw "Agent directory ancestor has an untrusted owner." + } + foreach ($rule in @($acl.GetAccessRules( + $true, $true, [System.Security.Principal.SecurityIdentifier] + ))) { + if ( + $rule.AccessControlType -eq + [System.Security.AccessControl.AccessControlType]::Allow -and + -not $trusted.ContainsKey($rule.IdentityReference.Value) -and + ($rule.PropagationFlags -band + [System.Security.AccessControl.PropagationFlags]::InheritOnly) -eq 0 -and + ($rule.FileSystemRights -band $dangerous) -ne 0 + ) { + throw "Agent directory ancestor permits untrusted namespace replacement." + } + } + } +} + +function Open-MinerOpsInstallerDirectoryLock { + param([Parameter(Mandatory)][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); +} +'@ + } + $handle = [MinerOpsDirectoryLockNative]::CreateFile( + [IO.Path]::GetFullPath($Path), 0, 3, [IntPtr]::Zero, 3, + 0x02200000, [IntPtr]::Zero + ) + if ($handle.IsInvalid) { + $code = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + $handle.Dispose() + throw (New-Object ComponentModel.Win32Exception( + $code, "Agent directory could not be locked against replacement." + )) + } + return $handle +} + +function Test-MinerOpsInstallerDirectoryAcl { + param([Parameter(Mandatory)]$Acl) + + if (-not $Acl.AreAccessRulesProtected) { return $false } + $trusted = @{} + foreach ($sid in (Get-MinerOpsInstallerDirectorySids)) { + $trusted[$sid] = $false + } + $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 + ) + foreach ($rule in @($Acl.GetAccessRules( + $true, $false, [System.Security.Principal.SecurityIdentifier] + ))) { + $sid = $rule.IdentityReference.Value + if ( + $rule.IsInherited -or + $rule.AccessControlType -ne + [System.Security.AccessControl.AccessControlType]::Allow + ) { 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 + } + elseif ($sid -eq "S-1-5-32-545") { + 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 + } + else { return $false } + } + return $usersRead -and -not ($trusted.Values -contains $false) +} + +function Assert-MinerOpsInstallerDirectory { + param([Parameter(Mandatory)][string]$Path) + + $fullPath = Assert-MinerOpsInstallerNoReparsePath -Path $Path + [void](Assert-MinerOpsInstallerTrustedAncestors -Path $fullPath) + if (-not (Test-MinerOpsInstallerDirectoryAcl ` + -Acl (Get-Acl -LiteralPath $fullPath -ErrorAction Stop))) { + throw "Agent directory owner or DACL is not trusted." + } + return $fullPath +} + +function Get-MinerOpsInstallerTransitionArtifacts { + param( + [Parameter(Mandatory)][string]$Path, + [string]$ExceptPath = "" + ) + + $exceptFull = if ($ExceptPath) { [IO.Path]::GetFullPath($ExceptPath) } else { "" } + return @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop | + Where-Object { + $full = [IO.Path]::GetFullPath($_.FullName) + -not ($exceptFull -and $full.Equals( + $exceptFull, [StringComparison]::OrdinalIgnoreCase + )) -and ( + $_.Name -eq "agent-credential-transition.recovery.json" -or + $_.Name -eq "agent-config.json.pending" -or + $_.Name -like "agent-config.pre-credential-transition*" -or + $_.Name -like "agent-config.json.*" -or + $_.Name -like "minerops-quarantine-credential-transition-*" -or + $_.Name -eq "minerops-credential-transition-quarantine" -or + $_.Name -like "minerops-credential-transition.blocked*.state" + ) + }) +} + +function Assert-MinerOpsInstallerChildPath { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$ExpectedParent, + [switch]$Directory + ) + + $fullPath = [IO.Path]::GetFullPath($Path) + $parent = [IO.Directory]::GetParent($fullPath).FullName.TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + $expected = [IO.Path]::GetFullPath($ExpectedParent).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + if (-not $parent.Equals($expected, [StringComparison]::OrdinalIgnoreCase)) { + throw "Installer target is outside the trusted Agent directory." + } + if (-not (Test-Path -LiteralPath $fullPath)) { return $fullPath } + $item = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop + if ( + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or + ($Directory -and -not $item.PSIsContainer) -or + (-not $Directory -and $item.PSIsContainer) + ) { + throw "Installer target is a reparse point or has the wrong type." + } + return $fullPath +} + +function Protect-MinerOpsInstallerExistingConfig { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$ExpectedParent + ) + + if (-not (Test-Path -LiteralPath $Path)) { return } + [void](Assert-MinerOpsInstallerDirectory -Path $ExpectedParent) + $fullPath = Assert-MinerOpsInstallerChildPath ` + -Path $Path -ExpectedParent $ExpectedParent + Assert-MinerOpsInstallerSingleLinkPath ` + -Path $fullPath -Description "Existing Agent config" + $stream = $null + try { + $stream = Open-MinerOpsInstallerFileStreamWithRights ` + -Path $fullPath ` + -Rights ([System.Security.AccessControl.FileSystemRights]( + [System.Security.AccessControl.FileSystemRights]::ReadPermissions -bor + [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [System.Security.AccessControl.FileSystemRights]::TakeOwnership -bor + [System.Security.AccessControl.FileSystemRights]::Synchronize + )) + Assert-MinerOpsInstallerSingleLinkStream -Stream $stream ` + -Description "Existing Agent config" + Set-MinerOpsInstallerStreamAcl -Stream $stream ` + -Acl (New-MinerOpsInstallerProtectedFileAcl) + if (-not (Test-MinerOpsInstallerProtectedFileAcl ` + -Acl (Get-MinerOpsInstallerStreamAcl -Stream $stream))) { + throw "Existing Agent config ACL could not be verified." + } + } + finally { + if ($null -ne $stream) { $stream.Dispose() } + } +} + +function Initialize-MinerOpsInstallerDirectory { + param( + [Parameter(Mandatory)][string]$Path, + [switch]$ApproveFilesystemTrustResetAfterReboot + ) + + $requestedPath = [IO.Path]::GetFullPath($Path) + $createdNew = $false + if (-not (Test-Path -LiteralPath $requestedPath)) { + $parent = [IO.Directory]::GetParent($requestedPath).FullName + [void](Assert-MinerOpsInstallerNoReparsePath -Path $parent) + [void](Assert-MinerOpsInstallerTrustedAncestors -Path $requestedPath) + $staging = Join-Path $parent ( + ".minerops-agent-create-{0}" -f [guid]::NewGuid().ToString("N") + ) + $stagingCreated = $false + try { + $directoryInfo = New-Object IO.DirectoryInfo($staging) + $directoryInfo.Create((New-MinerOpsInstallerDirectoryAcl)) + $stagingCreated = $true + if (-not (Test-MinerOpsInstallerDirectoryAcl ` + -Acl (Get-Acl -LiteralPath $staging -ErrorAction Stop))) { + throw "Staged Agent directory DACL could not be verified." + } + [void](Assert-MinerOpsInstallerTrustedAncestors -Path $requestedPath) + [IO.Directory]::Move($staging, $requestedPath) + $stagingCreated = $false + $createdNew = $true + } + finally { + if ($stagingCreated -and (Test-Path -LiteralPath $staging)) { + Remove-Item -LiteralPath $staging -Force -ErrorAction SilentlyContinue + } + } + } + $fullPath = Assert-MinerOpsInstallerNoReparsePath -Path $requestedPath + [void](Assert-MinerOpsInstallerTrustedAncestors -Path $fullPath) + $wasTrusted = Test-MinerOpsInstallerDirectoryAcl ` + -Acl (Get-Acl -LiteralPath $fullPath -ErrorAction Stop) + if (-not $wasTrusted) { + $directoryLock = $null + $trustBlockerPath = New-MinerOpsInstallerTrustBlocker ` + -TargetRoot $fullPath + try { + $directoryLock = Open-MinerOpsInstallerDirectoryLock -Path $fullPath + [void](Assert-MinerOpsInstallerNoReparsePath -Path $fullPath) + [void](Assert-MinerOpsInstallerTrustedAncestors -Path $fullPath) + Set-Acl -LiteralPath $fullPath ` + -AclObject (New-MinerOpsInstallerDirectoryAcl) + [void](Assert-MinerOpsInstallerDirectory -Path $fullPath) + } + finally { + if ($null -ne $directoryLock) { $directoryLock.Dispose() } + } + throw @" +The existing Agent directory was untrusted. It was hardened for containment, +but this invocation will not back up, restore, or publish runtime content. +Reboot and use the documented explicit trust-reset procedure. The external +filesystem trust blocker remains in force. +"@ + } + [void](Assert-MinerOpsInstallerDirectory -Path $fullPath) + $trustResetMode = $false + $trustBlockers = @( + Get-MinerOpsInstallerTrustBlockerPaths -Path $fullPath + ) + if ($trustBlockers.Count -gt 0) { + if (-not $ApproveFilesystemTrustResetAfterReboot) { + throw "Installer is blocked pending an explicit post-reboot filesystem trust reset." + } + # This mode is publication only. The credential-transition parent is + # solely responsible for boot authorization, strict task validation, + # convergence, and eventual blocker deletion. A direct installer run + # can therefore never complete the filesystem trust reset. + $trustResetMode = $true + } + Protect-MinerOpsInstallerExistingConfig ` + -Path (Join-Path $fullPath "agent-config.json") ` + -ExpectedParent $fullPath + if (@(Get-ChildItem -LiteralPath $fullPath ` + -Filter "minerops-credential-transition.blocked*.state" ` + -Force -ErrorAction Stop).Count -gt 0) { + throw "Credential transition is blocked pending operator review." + } + return [pscustomobject]@{ + Path = $fullPath + CreatedNew = $createdNew + TrustResetMode = $trustResetMode + TrustBlockerPaths = @($trustBlockers.FullName) + } +} + function Get-MinerOpsScheduledTask { param([Parameter(Mandatory)][string]$Name) - return Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue + $tasks = @(Get-ScheduledTask -ErrorAction Stop | Where-Object { + [string]$_.TaskName -ceq $Name + }) + if ($tasks.Count -gt 1) { + throw "Scheduled Task query returned multiple results for $Name." + } + if ($tasks.Count -eq 0) { return $null } + return $tasks[0] } function Stop-MinerOpsScheduledTaskForUpgrade { @@ -33,9 +967,12 @@ function Stop-MinerOpsScheduledTaskForUpgrade { if ($null -eq $task) { throw "Scheduled Task $Name disappeared while stopping it." } - if ([string]$task.State -ne "Running") { + if ([string]$task.State -eq "Ready") { return } + if ([string]$task.State -ne "Running") { + throw "Scheduled Task $Name entered invalid state '$($task.State)' while stopping." + } } while ([DateTime]::UtcNow -lt $deadline) throw "Scheduled Task $Name did not stop within $($Timeout.TotalSeconds) seconds." @@ -54,6 +991,8 @@ foreach ($name in $RuntimeFiles) { if (!(Test-Path -LiteralPath $source -PathType Leaf)) { throw "Deployment source is missing $name" } + Assert-MinerOpsInstallerSingleLinkPath ` + -Path $source -Description "Deployment source $name" } $mainTask = Get-MinerOpsScheduledTask -Name "MinerOpsAgent" @@ -61,19 +1000,60 @@ $mainTaskExisted = $null -ne $mainTask $mainTaskWasRunning = $mainTaskExisted -and [string]$mainTask.State -eq "Running" $mainTaskStoppedForUpgrade = $false $deploymentStarted = $false +$trustResetMode = $false $existing = @() $createdTaskNames = [System.Collections.Generic.List[string]]::new() try { - New-Item -ItemType Directory -Force -Path $Target, $BackupRoot | Out-Null + $directoryState = Initialize-MinerOpsInstallerDirectory ` + -Path $Target ` + -ApproveFilesystemTrustResetAfterReboot:$ApproveFilesystemTrustResetAfterReboot + $Target = [string]$directoryState.Path + $trustResetMode = [bool]$directoryState.TrustResetMode + $BackupRoot = Join-Path $Target "backups" + [void](Assert-MinerOpsInstallerDirectory -Path $Target) $existing = @($RuntimeFiles | Where-Object { - Test-Path -LiteralPath (Join-Path $Target $_) -PathType Leaf + $candidate = Join-Path $Target $_ + [void](Assert-MinerOpsInstallerChildPath ` + -Path $candidate -ExpectedParent $Target) + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + Assert-MinerOpsInstallerSingleLinkPath ` + -Path $candidate -Description "Installed runtime $_" + if (-not $trustResetMode) { + Assert-MinerOpsInstallerTrustedRuntimeFile ` + -Path $candidate -Description "Installed runtime $_" + } + return $true + } + return $false }) - if ($existing.Count -gt 0) { - New-Item -ItemType Directory -Force -Path $Backup | Out-Null - foreach ($name in $existing) { - Copy-Item -LiteralPath (Join-Path $Target $name) ` - -Destination (Join-Path $Backup $name) -Force + if (-not $trustResetMode) { + [void](Assert-MinerOpsInstallerChildPath ` + -Path $BackupRoot -ExpectedParent $Target -Directory) + if (-not (Test-Path -LiteralPath $BackupRoot)) { + New-Item -ItemType Directory -Path $BackupRoot | Out-Null + } + Set-Acl -LiteralPath $BackupRoot ` + -AclObject (New-MinerOpsInstallerDirectoryAcl) + if (-not (Test-MinerOpsInstallerDirectoryAcl ` + -Acl (Get-Acl -LiteralPath $BackupRoot -ErrorAction Stop))) { + throw "Backup directory owner or DACL could not be verified." + } + if ($existing.Count -gt 0) { + [void](Assert-MinerOpsInstallerDirectory -Path $Target) + if (Test-Path -LiteralPath $Backup) { + throw "Random installer backup destination already exists." + } + New-Item -ItemType Directory -Path $Backup | Out-Null + Set-Acl -LiteralPath $Backup ` + -AclObject (New-MinerOpsInstallerDirectoryAcl) + foreach ($name in $existing) { + $backupPath = Join-Path $Backup $name + Copy-Item -LiteralPath (Join-Path $Target $name) ` + -Destination $backupPath + Protect-MinerOpsInstallerRuntimeFile ` + -Path $backupPath -Description "Installer rollback copy $name" + } } } @@ -84,8 +1064,22 @@ try { $deploymentStarted = $true foreach ($name in $RuntimeFiles) { + [void](Assert-MinerOpsInstallerDirectory -Path $Target) + [void](Assert-MinerOpsInstallerChildPath ` + -Path (Join-Path $Target $name) -ExpectedParent $Target) + $targetPath = Join-Path $Target $name + if ($trustResetMode -and (Test-Path -LiteralPath $targetPath)) { + # Pre-reset runtime is evidence, never rollback authority. Reject + # aliases, then remove it without reading before publishing trusted + # package content into the now handle-clean namespace. + Assert-MinerOpsInstallerSingleLinkPath ` + -Path $targetPath -Description "Pre-reset runtime $name" + Remove-Item -LiteralPath $targetPath -Force -ErrorAction Stop + } Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) ` - -Destination (Join-Path $Target $name) -Force + -Destination $targetPath -Force + Protect-MinerOpsInstallerRuntimeFile ` + -Path $targetPath -Description "Published runtime $name" } $powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" @@ -126,7 +1120,11 @@ try { } ) foreach ($definition in $definitions) { - if ($null -ne (Get-MinerOpsScheduledTask -Name $definition.Name)) { + $existingTask = Get-MinerOpsScheduledTask -Name $definition.Name + if ($null -ne $existingTask) { + # Existing definitions remain authoritative and are never silently + # rewritten under SYSTEM. A suspect auxiliary task requires + # separate operator review; it is not credential authority. Write-Host "Preserving existing Scheduled Task $($definition.Name)." continue } @@ -150,12 +1148,23 @@ try { $createdTaskNames.Add($definition.Name) } - if ($mainTaskWasRunning) { + if ($mainTaskWasRunning -and -not $trustResetMode) { Start-MinerOpsScheduledTask -Name "MinerOpsAgent" $mainTaskStoppedForUpgrade = $false } + elseif ($trustResetMode) { + # A trust reset never starts an Agent against a merely contained + # pre-reset config. The credential-transition workflow positively + # verifies that config and restores the durable task intent later. + $mainTaskStoppedForUpgrade = $false + } - Write-Host "MinerOps Windows Agent installed. Existing task definitions, config, state, logs, and backups were preserved." + if ($trustResetMode) { + Write-Host "MinerOps Windows Agent runtime was published; the parent-owned filesystem trust blocker remains." + } + else { + Write-Host "MinerOps Windows Agent installed. Existing task definitions, config, state, logs, and backups were preserved." + } } catch { $deploymentError = $_ $rollbackErrors = [System.Collections.Generic.List[string]]::new() @@ -169,13 +1178,27 @@ try { } } - if ($deploymentStarted) { + if ($deploymentStarted -and -not $trustResetMode) { foreach ($name in $RuntimeFiles) { try { $targetPath = Join-Path $Target $name if ($existing -contains $name) { + Assert-MinerOpsInstallerSingleLinkPath ` + -Path (Join-Path $Backup $name) ` + -Description "Installer rollback source $name" + Assert-MinerOpsInstallerTrustedRuntimeFile ` + -Path (Join-Path $Backup $name) ` + -Description "Installer rollback source $name" + if (Test-Path -LiteralPath $targetPath) { + Assert-MinerOpsInstallerSingleLinkPath ` + -Path $targetPath ` + -Description "Failed published runtime $name" + } Copy-Item -LiteralPath (Join-Path $Backup $name) ` -Destination $targetPath -Force + Protect-MinerOpsInstallerRuntimeFile ` + -Path $targetPath ` + -Description "Restored runtime $name" } elseif (Test-Path -LiteralPath $targetPath) { Remove-Item -LiteralPath $targetPath -Force } @@ -185,7 +1208,8 @@ try { } } - if ($mainTaskWasRunning -and $mainTaskStoppedForUpgrade) { + if ($mainTaskWasRunning -and $mainTaskStoppedForUpgrade -and + -not $trustResetMode) { try { Start-MinerOpsScheduledTask -Name "MinerOpsAgent" $mainTaskStoppedForUpgrade = $false diff --git a/agents/windows/tests/Invoke-MinerOpsStandardUserProbe.ps1 b/agents/windows/tests/Invoke-MinerOpsStandardUserProbe.ps1 new file mode 100644 index 0000000..d10a04c --- /dev/null +++ b/agents/windows/tests/Invoke-MinerOpsStandardUserProbe.ps1 @@ -0,0 +1,247 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Operation, + [Parameter(Mandatory = $true)][string]$InputPath, + [string]$DestinationPath = "", + [ValidateSet("None", "Path")][string]$BackupMode = "None", + [string]$BackupPath = "", + [Parameter(Mandatory = $true)][string]$ResultPath, + [Parameter(Mandatory = $true)][string]$SupportPath +) + +$ErrorActionPreference = "Stop" +. $SupportPath + +$sourceCreatedByCaller = $false +$sourceReadVerified = $false +$sourceModifyVerified = $false +$sourceRenameVerified = $false +$sourceDeleteAccessVerified = $false +$sourceReplaceAccessVerified = $false +$replaceInvoked = $false +$sourceExistedBeforeAttack = $null +$destinationExistedBeforeAttack = $null +$backupExistedBeforeAttack = $null +$inputFullPath = "" +$destinationFullPath = "" +$backupFullPath = "" +$backupArgumentWasNativeNull = $false + +function Get-ProbeMetadata { + param( + [Parameter(Mandatory = $true)][string]$Status, + [Exception]$Exception + ) + + $inputOwnedByCaller = $false + if ([IO.File]::Exists($InputPath)) { + try { + $callerSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $ownerSid = [IO.File]::GetAccessControl($InputPath).GetOwner( + [Security.Principal.SecurityIdentifier] + ) + $inputOwnedByCaller = $callerSid.Equals($ownerSid) + } + catch { + $inputOwnedByCaller = $false + } + } + + $details = if ($null -ne $Exception) { + Get-MinerOpsNativeExceptionDetails -Exception $Exception + } + else { $null } + return [ordered]@{ + status = $Status + operation = $Operation + # Retain these two outer-exception fields for compatibility with + # earlier native-harness result readers. + exception_type = if ($null -ne $Exception) { + $Exception.GetType().FullName + } + else { + "" + } + hresult = if ($null -ne $details) { $details.OuterHResult } else { 0 } + outer_exception_type = if ($null -ne $details) { + $details.OuterExceptionType + } + else { "" } + outer_hresult = if ($null -ne $details) { + $details.OuterHResult + } + else { 0 } + leaf_exception_type = if ($null -ne $details) { + $details.LeafExceptionType + } + else { "" } + leaf_hresult = if ($null -ne $details) { + $details.LeafHResult + } + else { 0 } + native_error = if ($null -ne $details) { + $details.NativeError + } + else { $null } + exception_chain_types = if ($null -ne $details) { + @($details.ExceptionChainTypes) + } + else { @() } + exception_chain_truncated = if ($null -ne $details) { + [bool]$details.ExceptionChainTruncated + } + else { $false } + source_exists = [IO.File]::Exists($InputPath) + destination_exists = if ( + [string]::IsNullOrWhiteSpace($DestinationPath) + ) { + $null + } + else { + [IO.File]::Exists($DestinationPath) + } + input_owned_by_caller = $inputOwnedByCaller + source_created_by_caller = $sourceCreatedByCaller + source_read_verified = $sourceReadVerified + source_modify_verified = $sourceModifyVerified + source_rename_verified = $sourceRenameVerified + source_delete_access_verified = $sourceDeleteAccessVerified + source_replace_access_verified = $sourceReplaceAccessVerified + input_full_path = $inputFullPath + destination_full_path = $destinationFullPath + backup_mode = $BackupMode + backup_full_path = $backupFullPath + backup_argument_was_native_null = $backupArgumentWasNativeNull + source_existed_before_attack = $sourceExistedBeforeAttack + destination_existed_before_attack = $destinationExistedBeforeAttack + backup_existed_before_attack = $backupExistedBeforeAttack + replace_overload = "System.IO.File.Replace(String,String,String)" + replace_invoked = $replaceInvoked + } +} + +function Write-ProbeResult { + param([Parameter(Mandatory = $true)]$Value) + + $json = $Value | ConvertTo-Json -Compress + [IO.File]::WriteAllText($ResultPath, $json) +} + +$exception = $null +$status = "succeeded" +try { + switch ($Operation) { + "read" { [void][IO.File]::ReadAllText($InputPath) } + "create" { [IO.File]::WriteAllText($InputPath, "untrusted") } + "modify" { [IO.File]::WriteAllText($InputPath, "untrusted") } + "delete" { [IO.File]::Delete($InputPath) } + "rename" { [IO.File]::Move($InputPath, $DestinationPath) } + "prepare_swap" { + if ([IO.File]::Exists($InputPath)) { + throw [ArgumentException]::new( + "swap source unexpectedly existed before caller creation" + ) + } + $renameProof = "$InputPath.rename-proof-$([guid]::NewGuid().ToString('N'))" + [IO.File]::WriteAllText( + $InputPath, + "attacker-controlled-swap-source" + ) + $sourceCreatedByCaller = $true + $sourceReadVerified = [IO.File]::ReadAllText($InputPath) -ceq + "attacker-controlled-swap-source" + [IO.File]::AppendAllText($InputPath, "-modified") + $sourceModifyVerified = [IO.File]::ReadAllText($InputPath) -ceq + "attacker-controlled-swap-source-modified" + [IO.File]::Move($InputPath, $renameProof) + [IO.File]::Move($renameProof, $InputPath) + $sourceRenameVerified = [IO.File]::Exists($InputPath) -and + -not [IO.File]::Exists($renameProof) + + $requiredRights = + [Security.AccessControl.FileSystemRights]::ReadData -bor + [Security.AccessControl.FileSystemRights]::WriteData -bor + [Security.AccessControl.FileSystemRights]::Delete -bor + [Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [Security.AccessControl.FileSystemRights]::Synchronize + $rightsProbe = [IO.FileStream]::new( + $InputPath, + [IO.FileMode]::Open, + $requiredRights, + [IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete, + 4096, + [IO.FileOptions]::None + ) + try { + $sourceDeleteAccessVerified = $true + $sourceReplaceAccessVerified = $true + } + finally { + $rightsProbe.Dispose() + } + } + "swap" { + $inputFullPath = [IO.Path]::GetFullPath($InputPath) + $destinationFullPath = [IO.Path]::GetFullPath($DestinationPath) + if ($inputFullPath.Equals( + $destinationFullPath, + [StringComparison]::OrdinalIgnoreCase + )) { + throw [ArgumentException]::new( + "swap source and destination are the same path" + ) + } + + $sourceExistedBeforeAttack = [IO.File]::Exists($inputFullPath) + $destinationExistedBeforeAttack = + [IO.File]::Exists($destinationFullPath) + if (-not $sourceExistedBeforeAttack) { + throw [IO.FileNotFoundException]::new( + "swap source does not exist", + $inputFullPath + ) + } + if (-not $destinationExistedBeforeAttack) { + throw [IO.FileNotFoundException]::new( + "swap destination does not exist", + $destinationFullPath + ) + } + + if ($BackupMode -ceq "None") { + if (-not [string]::IsNullOrEmpty($BackupPath)) { + throw [ArgumentException]::new( + "backup path was supplied while backup mode is None" + ) + } + # A bare PowerShell $null is coerced to String.Empty for this + # .NET string parameter. NullString is the explicit native + # null required to request no ReplaceFile backup. + $backupArgumentWasNativeNull = $true + } + else { + throw [ArgumentException]::new( + "backup-producing swap fixtures are prohibited because " + + "they can expose protected destination content" + ) + } + + $replaceInvoked = $true + [IO.File]::Replace( + $inputFullPath, + $destinationFullPath, + [NullString]::Value + ) + } + default { throw [ArgumentException]::new("unknown operation") } + } +} +catch { + $exception = $_.Exception + $status = Get-MinerOpsNativeExceptionStatus ` + -Operation $Operation -Exception $exception +} + +$result = Get-ProbeMetadata -Status $status -Exception $exception +Write-ProbeResult -Value $result +exit (Get-MinerOpsNativeStatusExitCode -Status $status) diff --git a/agents/windows/tests/MinerOpsNativeHarnessSupport.ps1 b/agents/windows/tests/MinerOpsNativeHarnessSupport.ps1 new file mode 100644 index 0000000..edb2364 --- /dev/null +++ b/agents/windows/tests/MinerOpsNativeHarnessSupport.ps1 @@ -0,0 +1,475 @@ +$ErrorActionPreference = "Stop" + +function Get-MinerOpsNativeGetItemState { + $effective = Get-Command Get-Item -ErrorAction Stop + $function = if ( + $effective.CommandType -eq + [Management.Automation.CommandTypes]::Function + ) { + $effective + } + else { + $null + } + + return [pscustomobject]@{ + FunctionExisted = $null -ne $function + FunctionScriptBlock = if ($null -ne $function) { + $function.ScriptBlock + } + else { + $null + } + EffectiveCommandType = [string]$effective.CommandType + EffectiveName = [string]$effective.Name + EffectiveModuleName = [string]$effective.ModuleName + EffectiveSource = [string]$effective.Source + EffectiveDefinition = [string]$effective.Definition + } +} + +function Assert-MinerOpsNativeGetItemState { + param( + [Parameter(Mandatory = $true)]$Expected, + [string]$Context = "Get-Item state check" + ) + + $actual = Get-MinerOpsNativeGetItemState + if ([bool]$actual.FunctionExisted -ne [bool]$Expected.FunctionExisted) { + throw "$Context changed the global Get-Item function's existence." + } + if ( + [bool]$Expected.FunctionExisted -and + ( + -not [object]::ReferenceEquals( + $actual.FunctionScriptBlock, + $Expected.FunctionScriptBlock + ) -or + $actual.FunctionScriptBlock.ToString() -cne + $Expected.FunctionScriptBlock.ToString() + ) + ) { + throw "$Context changed the global Get-Item function's ScriptBlock." + } + foreach ($property in @( + "EffectiveCommandType", + "EffectiveName", + "EffectiveModuleName", + "EffectiveSource", + "EffectiveDefinition" + )) { + if ([string]$actual.$property -cne [string]$Expected.$property) { + throw "$Context changed effective Get-Item resolution ($property)." + } + } +} + +function Invoke-MinerOpsNativeGetItemTrace { + param([Parameter(Mandatory = $true)][scriptblock]$Action) + + $before = Get-MinerOpsNativeGetItemState + $observedGetItemPaths = [System.Collections.Generic.List[string]]::new() + $invokeWithLocalHook = { + param( + [Parameter(Mandatory = $true)][scriptblock]$TraceAction, + [Parameter(Mandatory = $true)]$ObservedPaths + ) + + # Use a child scope because PowerShell can continue resolving a + # removed active function until the scope that defined it exits. + # Exiting this child scope makes cleanup deterministic before the + # caller-state assertion runs. + Set-Item -LiteralPath "Function:\local:Get-Item" -Value { + [CmdletBinding(DefaultParameterSetName = "Path")] + param( + [Parameter(Position = 0, ParameterSetName = "Path")] + [string[]]$Path, + [Parameter(Mandatory = $true, ParameterSetName = "LiteralPath")] + [Alias("PSPath")] + [string[]]$LiteralPath, + [switch]$Force + ) + + $observed = if ($PSCmdlet.ParameterSetName -eq "LiteralPath") { + $LiteralPath + } + else { + $Path + } + foreach ($candidate in @($observed)) { + $ObservedPaths.Add([string]$candidate) + } + Microsoft.PowerShell.Management\Get-Item @PSBoundParameters + } + try { + & $TraceAction | Out-Null + } + finally { + Remove-Item -LiteralPath "Function:\local:Get-Item" -Force ` + -ErrorAction SilentlyContinue + } + } + try { + & $invokeWithLocalHook $Action $observedGetItemPaths + return @($observedGetItemPaths) + } + finally { + Assert-MinerOpsNativeGetItemState -Expected $before ` + -Context "Invoke-MinerOpsNativeGetItemTrace cleanup" + } +} + +function Get-MinerOpsNativeWin32Error { + param([Parameter(Mandatory = $true)][int]$HResult) + + # HRESULT_FROM_WIN32 values have the 0x8007 facility prefix. Do not treat + # the low word of an arbitrary CLR or PowerShell HRESULT as a Win32 error. + $unsignedHResult = [int64]$HResult -band 4294967295L + if ( + ($unsignedHResult -band 4294901760L) -eq 2147942400L + ) { + return [int]($unsignedHResult -band 65535L) + } + + return $null +} + +function Get-MinerOpsNativeExceptionDetails { + param([Parameter(Mandatory = $true)][Exception]$Exception) + + $maximumDepth = 32 + $visited = [System.Collections.Generic.List[Exception]]::new() + $chainTypes = [System.Collections.Generic.List[string]]::new() + $current = $Exception + $leaf = $Exception + $truncated = $false + + for ($depth = 0; $depth -lt $maximumDepth; $depth++) { + if ($null -eq $current) { break } + + $alreadyVisited = $false + foreach ($candidate in $visited) { + if ([object]::ReferenceEquals($candidate, $current)) { + $alreadyVisited = $true + break + } + } + if ($alreadyVisited) { + $truncated = $true + break + } + + $visited.Add($current) + $chainTypes.Add($current.GetType().FullName) + $leaf = $current + $current = $current.InnerException + } + if ($null -ne $current) { + $truncated = $true + } + + $nativeError = Get-MinerOpsNativeWin32Error -HResult ([int]$leaf.HResult) + return [pscustomobject]@{ + OuterException = $Exception + OuterExceptionType = $Exception.GetType().FullName + OuterHResult = [int]$Exception.HResult + LeafException = $leaf + LeafExceptionType = $leaf.GetType().FullName + LeafHResult = [int]$leaf.HResult + NativeError = $nativeError + ExceptionChainTypes = @($chainTypes) + ExceptionChainTruncated = $truncated + } +} + +function Get-MinerOpsNativeExceptionStatus { + param( + [Parameter(Mandatory = $true)][string]$Operation, + [Parameter(Mandatory = $true)][Exception]$Exception + ) + + $details = Get-MinerOpsNativeExceptionDetails -Exception $Exception + $meaningfulException = $details.LeafException + $nativeError = $details.NativeError + + if ($details.ExceptionChainTruncated) { + return "unexpected_failure" + } + + if ($meaningfulException -is [UnauthorizedAccessException]) { + return "security_denied" + } + + if ( + $meaningfulException -is [ArgumentException] -or + $meaningfulException -is [IO.FileNotFoundException] -or + $meaningfulException -is [IO.DirectoryNotFoundException] -or + $meaningfulException -is [IO.DriveNotFoundException] -or + $meaningfulException -is [IO.PathTooLongException] -or + $meaningfulException -is [NotSupportedException] + ) { + return "fixture_error" + } + + if ( + $meaningfulException -is [IO.IOException] -and + $null -ne $nativeError + ) { + if ([int]$nativeError -eq 5) { + return "security_denied" + } + + if ($Operation -ceq "swap" -and [int]$nativeError -eq 1175) { + # ReplaceFileW documents ERROR_UNABLE_TO_REMOVE_REPLACED as + # retaining both original names. The caller must still prove every + # security and identity postcondition before treating this as a + # passing denial. + return "security_denied_requires_postconditions" + } + + if ([int]$nativeError -eq 17) { + # ReplaceFile requires source and destination on the same volume. + # This says nothing about the protected target's access boundary. + return "api_precondition_failure" + } + } + + return "unexpected_failure" +} + +function Get-MinerOpsNativeStatusExitCode { + param([Parameter(Mandatory = $true)][string]$Status) + + switch ($Status) { + "succeeded" { return 0 } + "security_denied" { return 5 } + "fixture_error" { return 6 } + "unexpected_failure" { return 7 } + "security_denied_requires_postconditions" { return 8 } + "api_precondition_failure" { return 9 } + default { throw "Unknown standard-user helper status '$Status'." } + } +} + +function Get-MinerOpsNativeSwapFixtureStatus { + param([Parameter(Mandatory = $true)]$Fixture) + + $fixtureFailures = [System.Collections.Generic.List[string]]::new() + $requiredFixtureChecks = [ordered]@{ + SourceExists = "source is missing" + TargetExists = "target is missing" + PathsDistinct = "source and target are the same path" + SourceCreatedByStandardUser = ` + "source was not created by the standard-user process" + SourceOwnedByStandardUser = ` + "standard user does not own the source" + SourceReadable = "standard user cannot read the source" + SourceModifiable = "standard user cannot modify the source" + SourceRenameable = "standard user cannot rename the source" + SourceDeleteCapable = ` + "standard user does not have required delete access to the source" + SourceReplaceAccessVerified = ` + "standard user lacks source access required by ReplaceFile" + TargetAclTrusted = "target ACL was not trusted before attack" + TargetOwnerTrusted = "target owner was not trusted before attack" + TargetIdentityCaptured = "target identity was not captured" + SourceIdentityCaptured = "source identity was not captured" + FileIdentitiesDistinct = "source and target resolve to the same file" + BackupArgumentValid = "backup argument is invalid" + BackupPathsDistinct = ` + "backup path is not distinct from source and target" + BackupExpectedStateVerified = ` + "backup pre-attack state was not verified" + BackupConfidentialitySafe = ` + "backup choice could expose protected target content" + } + foreach ($entry in $requiredFixtureChecks.GetEnumerator()) { + if (-not [bool]$Fixture.($entry.Key)) { + $fixtureFailures.Add([string]$entry.Value) + } + } + if ($fixtureFailures.Count -gt 0) { + return [pscustomobject]@{ + Status = "fixture_error" + Failures = @($fixtureFailures) + } + } + + $apiFailures = [System.Collections.Generic.List[string]]::new() + if (-not [bool]$Fixture.SameVolume) { + $apiFailures.Add("source, target, and backup are not on one volume") + } + if (-not [bool]$Fixture.NtfsVolume) { + $apiFailures.Add("attack fixture is not on NTFS") + } + if ($apiFailures.Count -gt 0) { + return [pscustomobject]@{ + Status = "api_precondition_failure" + Failures = @($apiFailures) + } + } + + return [pscustomobject]@{ + Status = "ready" + Failures = @() + } +} + +function Assert-MinerOpsNativeSwapFixtureReady { + param([Parameter(Mandatory = $true)]$Fixture) + + $classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $Fixture + switch ([string]$classification.Status) { + "ready" { return } + "fixture_error" { + throw "Standard-user swap fixture is invalid: " + + "$(@($classification.Failures) -join '; ')." + } + "api_precondition_failure" { + throw "Standard-user swap has an API precondition failure: " + + "$(@($classification.Failures) -join '; ')." + } + default { + throw "Standard-user swap fixture classification failed closed." + } + } +} + +function Assert-MinerOpsNativeSwapSecurityOutcome { + param( + [Parameter(Mandatory = $true)]$Result, + [Parameter(Mandatory = $true)]$Postconditions + ) + + if ([string]$Result.operation -cne "swap") { + throw "Swap result recorded the wrong operation." + } + $detail = "outer=$([string]$Result.outer_exception_type), " + + "outer_hresult=$([string]$Result.outer_hresult), " + + "leaf=$([string]$Result.leaf_exception_type), " + + "leaf_hresult=$([string]$Result.leaf_hresult), " + + "native=$([string]$Result.native_error), " + + "chain=$(@($Result.exception_chain_types) -join ' -> ')" + switch ([string]$Result.status) { + "succeeded" { + if (-not [bool]$Result.replace_invoked) { + throw "Successful swap result did not record File.Replace invocation." + } + throw "Standard user completed the protected replacement swap." + } + "security_denied" { } + "security_denied_requires_postconditions" { } + "fixture_error" { + throw "Standard-user swap fixture was invalid ($detail)." + } + "api_precondition_failure" { + throw "Standard-user swap hit an unrelated API precondition ($detail)." + } + default { + throw "Standard-user swap failed unexpectedly ($detail)." + } + } + if (-not [bool]$Result.replace_invoked) { + throw "Standard-user swap did not reach System.IO.File.Replace." + } + + $required = [ordered]@{ + TargetExists = "protected target is missing" + TargetIdentityUnchanged = "protected target identity changed" + TargetContentUnchanged = "protected target content changed" + TargetAclTrusted = "protected target ACL is not trusted" + TargetAclUnchanged = "protected target ACL changed" + TargetOwnerTrusted = "protected target owner is not trusted" + TargetOwnerUnchanged = "protected target owner changed" + TargetSingleLink = "protected target is not single-link" + SourceExists = "attacker source is missing" + SourceIdentityUnchanged = "attacker source identity changed" + SourceContentUnchanged = "attacker source content changed" + AttackerSourceNotPromoted = "attacker source was promoted to authority" + TargetDeniedStandardUserRead = ` + "protected content became readable by the standard user" + TargetDeniedStandardUserModify = ` + "protected target became modifiable by the standard user" + TargetDeniedStandardUserDelete = ` + "protected target became deletable by the standard user" + TargetDeniedStandardUserRename = ` + "protected target became renameable by the standard user" + NoAttackerReadableBackup = ` + "replacement exposed protected content through a backup" + } + $failures = [System.Collections.Generic.List[string]]::new() + foreach ($entry in $required.GetEnumerator()) { + if (-not [bool]$Postconditions.($entry.Key)) { + $failures.Add([string]$entry.Value) + } + } + if ($failures.Count -gt 0) { + throw "Swap security postconditions failed ($detail): " + + "$($failures -join '; ')." + } +} + +function Get-MinerOpsNativeFileIdentity { + param([Parameter(Mandatory = $true)]$Stream) + + if (-not ("MinerOpsNativeHarnessFileIdentity" -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public sealed class MinerOpsNativeHarnessFileIdentityResult +{ + public string Identity { get; set; } + public uint VolumeSerialNumber { get; set; } + public ulong FileIndex { get; set; } + public uint NumberOfLinks { get; set; } +} + +public static class MinerOpsNativeHarnessFileIdentity +{ + [StructLayout(LayoutKind.Sequential)] + private struct ByHandleFileInformation + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle file, out ByHandleFileInformation information); + + public static MinerOpsNativeHarnessFileIdentityResult Get( + SafeFileHandle file) + { + ByHandleFileInformation information; + if (!GetFileInformationByHandle(file, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + ulong fileIndex = ((ulong)information.FileIndexHigh << 32) | + information.FileIndexLow; + return new MinerOpsNativeHarnessFileIdentityResult { + Identity = String.Format( + "{0:X8}:{1:X8}{2:X8}", + information.VolumeSerialNumber, + information.FileIndexHigh, + information.FileIndexLow), + VolumeSerialNumber = information.VolumeSerialNumber, + FileIndex = fileIndex, + NumberOfLinks = information.NumberOfLinks + }; + } +} +'@ + } + + return [MinerOpsNativeHarnessFileIdentity]::Get($Stream.SafeFileHandle) +} diff --git a/agents/windows/tests/Test-MinerOpsBootEventParser.ps1 b/agents/windows/tests/Test-MinerOpsBootEventParser.ps1 new file mode 100644 index 0000000..2c8b445 --- /dev/null +++ b/agents/windows/tests/Test-MinerOpsBootEventParser.ps1 @@ -0,0 +1,335 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path $PSScriptRoot -Parent +$scriptPath = Join-Path $root "Upgrade-MinerOpsAgentCredential.ps1" +. $scriptPath + +$script:Passed = 0 +$script:BootEventRecords = @() +$script:ToXmlCalls = 0 + +function Assert-True { + param( + [Parameter(Mandatory)][bool]$Condition, + [Parameter(Mandatory)][string]$Message + ) + if (-not $Condition) { throw $Message } +} + +function Assert-Throws { + param( + [Parameter(Mandatory)][scriptblock]$Action, + [string]$Pattern = "" + ) + try { & $Action | Out-Null } + catch { + if ($Pattern -and $_.Exception.Message -notmatch $Pattern) { + throw "Expected error '$Pattern', got '$($_.Exception.Message)'." + } + return + } + throw "Expected action to fail." +} + +function Invoke-ParserTest { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][scriptblock]$Action + ) + & $Action + $script:Passed++ + Write-Host "PASS: $Name" +} + +function New-TestKernelBootXml { + param( + [string]$Namespace = + "http://schemas.microsoft.com/win/2004/08/events/event", + [string]$BootTypeContent = '0', + [switch]$OmitSystem, + [switch]$OmitEventData, + [switch]$DuplicateEventData + ) + + $system = if ($OmitSystem) { "" } else { +@" + + + 27 + 1 + 4 + 33 + 0 + 42 + System + +"@ + } + $eventData = if ($OmitEventData) { "" } else { + " $BootTypeContent" + } + if ($DuplicateEventData) { + $eventData += "`n $BootTypeContent" + } + return "`n$system$eventData`n" +} + +function New-TestEventRecord { + param( + [AllowNull()]$ProviderName = "Microsoft-Windows-Kernel-Boot", + [AllowNull()]$Id = [int]27, + [AllowNull()]$RecordId = [long]42, + [AllowNull()]$Version = [byte]1, + [AllowNull()]$Xml = (New-TestKernelBootXml) + ) + + $record = [pscustomobject]@{ + ProviderName = $ProviderName + Id = $Id + RecordId = $RecordId + Version = $Version + XmlPayload = $Xml + } + $record | Add-Member -MemberType ScriptMethod -Name ToXml -Value { + $script:ToXmlCalls++ + return $this.XmlPayload + } + return $record +} + +function Get-WinEvent { + param($FilterHashtable, $ErrorAction) + return $script:BootEventRecords +} + +Invoke-ParserTest "conventional namespaced Event XML and typed metadata parse" { + $parsed = ConvertFrom-MinerOpsKernelBootEventRecord ` + -EventRecord (New-TestEventRecord) + Assert-True ($parsed.ProviderName -ceq + "Microsoft-Windows-Kernel-Boot") "Typed provider was not retained." + Assert-True ($parsed.EventId -is [int] -and $parsed.EventId -eq 27) ` + "Typed event ID was not retained." + Assert-True ($parsed.RecordId -is [long] -and $parsed.RecordId -eq 42) ` + "Typed record ID was not retained." + Assert-True ($parsed.Version -is [byte] -and $parsed.Version -eq 1) ` + "Typed event version was not retained." + Assert-True ($parsed.BootType -is [UInt32] -and $parsed.BootType -eq 0) ` + "BootType payload was not parsed." +} + +Invoke-ParserTest "supported event version zero parses" { + $parsed = ConvertFrom-MinerOpsKernelBootEventRecord ` + -EventRecord (New-TestEventRecord -Version ([byte]0)) + Assert-True ($parsed.Version -eq 0) "Version zero was not accepted." +} + +Invoke-ParserTest "production Get-WinEvent path invokes ToXml parser" { + $script:ToXmlCalls = 0 + $script:BootEventRecords = @( + New-TestEventRecord -RecordId ([long]43) + ) + $identifier = Get-MinerOpsBootIdentifier + Assert-True ($identifier -ceq + "windows-full-boot-event-v1:000000000000002b") ` + "Production event path returned the wrong identifier." + Assert-True ($script:ToXmlCalls -eq 1) ` + "Production event path did not invoke ToXml exactly once." +} + +Invoke-ParserTest "typed metadata is authoritative over XML scalar metadata" { + $xml = (New-TestKernelBootXml).Replace( + "27", "999" + ).Replace( + "42", + "999" + ) + $parsed = ConvertFrom-MinerOpsKernelBootEventRecord ` + -EventRecord (New-TestEventRecord -Xml $xml) + Assert-True ($parsed.EventId -eq 27 -and $parsed.RecordId -eq 42) ` + "XML scalar metadata overrode typed EventRecord properties." +} + +Invoke-ParserTest "null typed RecordId fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord ` + -EventRecord (New-TestEventRecord -RecordId $null) + } "metadata" +} + +Invoke-ParserTest "wrong typed provider fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord ` + (New-TestEventRecord -ProviderName "Unexpected-Provider") + } "metadata" +} + +Invoke-ParserTest "wrong typed event ID fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord ` + -EventRecord (New-TestEventRecord -Id ([int]28)) + } "metadata" +} + +Invoke-ParserTest "wrong RecordId type fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord ` + -EventRecord (New-TestEventRecord -RecordId ([UInt64]42)) + } "metadata" +} + +Invoke-ParserTest "null event version fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord ` + -EventRecord (New-TestEventRecord -Version $null) + } "metadata" +} + +Invoke-ParserTest "unsupported event version fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord ` + -EventRecord (New-TestEventRecord -Version ([byte]2)) + } "unsupported" +} + +Invoke-ParserTest "unexpected Event XML namespace fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord ` + (New-TestEventRecord -Xml (New-TestKernelBootXml ` + -Namespace "urn:unexpected")) + } "root" +} + +Invoke-ParserTest "malformed Event XML fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord ` + -EventRecord (New-TestEventRecord -Xml "") + } "malformed" +} + +Invoke-ParserTest "missing System element fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord ` + (New-TestEventRecord -Xml (New-TestKernelBootXml -OmitSystem)) + } "schema" +} + +Invoke-ParserTest "missing EventData fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord ` + (New-TestEventRecord -Xml (New-TestKernelBootXml -OmitEventData)) + } "schema" +} + +Invoke-ParserTest "duplicated EventData fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord ` + (New-TestEventRecord -Xml ` + (New-TestKernelBootXml -DuplicateEventData)) + } "schema" +} + +Invoke-ParserTest "missing BootType fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord ` + (New-TestEventRecord -Xml (New-TestKernelBootXml ` + -BootTypeContent '')) + } "schema" +} + +Invoke-ParserTest "duplicate BootType fails closed" { + $payload = '0' + + '0' + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord ` + (New-TestEventRecord -Xml (New-TestKernelBootXml ` + -BootTypeContent $payload)) + } "ambiguous" +} + +Invoke-ParserTest "nonnumeric BootType fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord ` + (New-TestEventRecord -Xml (New-TestKernelBootXml ` + -BootTypeContent 'full')) + } "canonical" +} + +Invoke-ParserTest "noncanonical BootType fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord ` + (New-TestEventRecord -Xml (New-TestKernelBootXml ` + -BootTypeContent '00')) + } "canonical" +} + +Invoke-ParserTest "structured BootType content fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord ` + (New-TestEventRecord -Xml (New-TestKernelBootXml ` + -BootTypeContent ` + '0')) + } "schema" +} + +Invoke-ParserTest "BootType outside UInt32 fails closed" { + Assert-Throws { + ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord ` + (New-TestEventRecord -Xml (New-TestKernelBootXml ` + -BootTypeContent ` + '4294967296')) + } "invalid" +} + +Invoke-ParserTest "nonzero BootType is not a full-boot anchor" { + $script:BootEventRecords = @( + New-TestEventRecord -Xml (New-TestKernelBootXml ` + -BootTypeContent '1') + ) + Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified" +} + +Invoke-ParserTest "empty event query fails closed" { + $script:BootEventRecords = @() + Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified" +} + +Invoke-ParserTest "duplicate qualifying record IDs fail closed" { + $script:BootEventRecords = @( + New-TestEventRecord -RecordId ([long]42) + New-TestEventRecord -RecordId ([long]43) + New-TestEventRecord -RecordId ([long]42) + ) + Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified" +} + +Invoke-ParserTest "equal stored and current anchors deny reset" { + Assert-Throws { + Assert-MinerOpsBootEpochChanged ` + -StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" ` + -CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002a" + } "without a verified full Restart" +} + +Invoke-ParserTest "strictly greater current anchor passes ordering" { + Assert-MinerOpsBootEpochChanged ` + -StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" ` + -CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b" +} + +Invoke-ParserTest "smaller current anchor fails closed" { + Assert-Throws { + Assert-MinerOpsBootEpochChanged ` + -StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" ` + -CurrentBootIdentifier "windows-full-boot-event-v1:0000000000000029" + } "older than the" +} + +Invoke-ParserTest "malformed stored anchor fails closed" { + Assert-Throws { + Assert-MinerOpsBootEpochChanged ` + -StoredBootIdentifier "windows-full-boot-event-v1:not-a-record" ` + -CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b" + } "canonical" +} + +Write-Host "$script:Passed boot-event parser tests passed." diff --git a/agents/windows/tests/Test-MinerOpsCredentialTransition.Windows.ps1 b/agents/windows/tests/Test-MinerOpsCredentialTransition.Windows.ps1 new file mode 100644 index 0000000..cff4bc9 --- /dev/null +++ b/agents/windows/tests/Test-MinerOpsCredentialTransition.Windows.ps1 @@ -0,0 +1,1278 @@ +#Requires -RunAsAdministrator +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [switch]$AcknowledgeDisposableVm +) + +$ErrorActionPreference = "Stop" + +if (-not $IsWindows -and $PSVersionTable.PSEdition -ne "Desktop") { + throw "This validation must run on native Windows." +} +if (-not $AcknowledgeDisposableVm) { + throw "Pass -AcknowledgeDisposableVm on an isolated disposable Windows VM." +} + +$root = Split-Path $PSScriptRoot -Parent +$transitionScript = Join-Path $root "Upgrade-MinerOpsAgentCredential.ps1" +$enrollmentScript = Join-Path $root "Enroll-MinerOpsAgent.ps1" +$installerScript = Join-Path $root "install.ps1" +$harnessSupportScript = Join-Path $PSScriptRoot ` + "MinerOpsNativeHarnessSupport.ps1" +$standardUserProbeScript = Join-Path $PSScriptRoot ` + "Invoke-MinerOpsStandardUserProbe.ps1" +. $harnessSupportScript +$originalGetItemState = Get-MinerOpsNativeGetItemState +. $transitionScript + +function Import-NativeFunctionDefinition { + param( + [Parameter(Mandatory = $true)][string]$ScriptPath, + [Parameter(Mandatory = $true)][string]$Name + ) + + $tokens = $null + $parseErrors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile( + $ScriptPath, + [ref]$tokens, + [ref]$parseErrors + ) + if ($parseErrors.Count -gt 0) { + throw "$ScriptPath parser errors: $($parseErrors.Message -join '; ')" + } + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq $Name + }, $true)) + if ($definitions.Count -ne 1) { + throw "Expected exactly one $Name definition in $ScriptPath." + } + Set-Item -Path ("Function:\script:{0}" -f $Name) ` + -Value $definitions[0].Body.GetScriptBlock() +} + +foreach ($functionImport in @( + @{ + Path = $enrollmentScript + Name = "Assert-MinerOpsEnrollmentNoReparsePath" + }, + @{ + Path = $enrollmentScript + Name = "Assert-MinerOpsEnrollmentTrustedAncestors" + }, + @{ + Path = $installerScript + Name = "Assert-MinerOpsInstallerNoReparsePath" + }, + @{ + Path = $installerScript + Name = "Assert-MinerOpsInstallerTrustedAncestors" + }, + @{ + Path = $enrollmentScript + Name = "Get-MinerOpsEnrollmentDirectorySids" + }, + @{ + Path = $enrollmentScript + Name = "New-MinerOpsEnrollmentCredentialAcl" + }, + @{ + Path = $enrollmentScript + Name = "New-MinerOpsEnrollmentProtectedFileCreateNewStream" + }, + @{ + Path = $enrollmentScript + Name = "Get-MinerOpsEnrollmentStreamAcl" + }, + @{ + Path = $enrollmentScript + Name = "Test-MinerOpsEnrollmentCredentialAcl" + }, + @{ + Path = $installerScript + Name = "Get-MinerOpsInstallerDirectorySids" + }, + @{ + Path = $installerScript + Name = "New-MinerOpsInstallerProtectedFileAcl" + }, + @{ + Path = $installerScript + Name = "New-MinerOpsInstallerProtectedFileCreateNewStream" + }, + @{ + Path = $installerScript + Name = "Get-MinerOpsInstallerStreamAcl" + }, + @{ + Path = $installerScript + Name = "Test-MinerOpsInstallerProtectedFileAcl" + } +)) { + Import-NativeFunctionDefinition ` + -ScriptPath ([string]$functionImport.Path) ` + -Name ([string]$functionImport.Name) +} + +$validationId = [guid]::NewGuid().ToString("N").Substring(0, 10) +$workRoot = Join-Path $env:ProgramData "MinerOpsCredentialValidation-$validationId" +$taskName = "MinerOpsCredentialValidation-$validationId" +$userName = "mopsval$($validationId.Substring(0, 8))" +$publicResult = Join-Path $env:PUBLIC "minerops-acl-result-$validationId.txt" +$readerPath = Join-Path $env:PUBLIC "minerops-acl-reader-$validationId.ps1" +$readerSupportPath = Join-Path $env:PUBLIC ` + "minerops-native-harness-support-$validationId.ps1" +$testUser = $null +$taskCreated = $false +$passed = 0 +$originalPowerShellLocation = (Get-Location).Path +$originalProcessCurrentDirectory = [Environment]::CurrentDirectory + +function Assert-Native { + param( + [Parameter(Mandatory = $true)][bool]$Condition, + [Parameter(Mandatory = $true)][string]$Message + ) + if (-not $Condition) { throw $Message } +} + +function Invoke-NativeTest { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][scriptblock]$Action + ) + & $Action + $script:passed++ + Write-Host "PASS: $Name" +} + +function Invoke-NativeGetItemTrace { + param([Parameter(Mandatory = $true)][scriptblock]$Action) + + return @(Invoke-MinerOpsNativeGetItemTrace -Action $Action) +} + +function Assert-AllNativeRootValidators { + param([Parameter(Mandatory = $true)][string]$Path) + + [void](Assert-MinerOpsNoReparseDirectoryPath -Path $Path) + [void](Assert-MinerOpsTrustedAncestorNamespace -Path $Path) + [void](Assert-MinerOpsEnrollmentNoReparsePath -Path $Path) + [void](Assert-MinerOpsEnrollmentTrustedAncestors -Path $Path) + [void](Assert-MinerOpsInstallerNoReparsePath -Path $Path) + [void](Assert-MinerOpsInstallerTrustedAncestors -Path $Path) +} + +function Invoke-StandardUserOperation { + param( + [Parameter(Mandatory = $true)] + [ValidateSet( + "read", "create", "modify", "delete", "rename", "prepare_swap", + "swap" + )] + [string]$Operation, + [Parameter(Mandatory = $true)][string]$Path, + [string]$Destination = "", + [ValidateSet("None", "Path")][string]$BackupMode = "None", + [string]$BackupPath = "" + ) + + Remove-Item -LiteralPath $publicResult -Force -ErrorAction SilentlyContinue + $arguments = @( + "-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-File", ('"{0}"' -f $readerPath), + "-Operation", $Operation, + "-InputPath", ('"{0}"' -f $Path), + "-DestinationPath", ('"{0}"' -f $Destination), + "-BackupMode", $BackupMode, + "-BackupPath", ('"{0}"' -f $BackupPath), + "-ResultPath", ('"{0}"' -f $publicResult), + "-SupportPath", ('"{0}"' -f $readerSupportPath) + ) -join " " + $process = Start-Process ` + -FilePath "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" ` + -Credential $testUser ` + -ArgumentList $arguments ` + -LoadUserProfile ` + -Wait ` + -PassThru + Assert-Native (Test-Path -LiteralPath $publicResult -PathType Leaf) ` + "Standard-user $Operation helper did not write a result." + try { + $result = Get-Content -LiteralPath $publicResult -Raw | ConvertFrom-Json + } + catch { + throw "Standard-user $Operation helper wrote an invalid result." + } + Assert-Native ([string]$result.operation -ceq $Operation) ` + "Standard-user helper recorded the wrong operation." + $expectedExit = Get-MinerOpsNativeStatusExitCode ` + -Status ([string]$result.status) + Assert-Native ($process.ExitCode -eq $expectedExit) ` + "Standard-user $Operation helper exit/result mismatch." + return $result +} + +function Assert-StandardUserDenied { + param( + [Parameter(Mandatory = $true)] + [ValidateSet("read", "create", "modify", "delete", "rename")] + [string]$Operation, + [Parameter(Mandatory = $true)][string]$Path, + [string]$Destination = "" + ) + + $result = Invoke-StandardUserOperation ` + -Operation $Operation -Path $Path -Destination $Destination + $detail = "outer=$($result.outer_exception_type), " + + "outer_hresult=$($result.outer_hresult), " + + "leaf=$($result.leaf_exception_type), " + + "leaf_hresult=$($result.leaf_hresult), " + + "native=$($result.native_error), " + + "chain=$(@($result.exception_chain_types) -join ' -> ')" + Assert-Native ([string]$result.status -ceq "security_denied") ` + "Standard-user $Operation was not denied by security ($detail)." +} + +function Get-NativeStreamSha256 { + param([Parameter(Mandatory = $true)]$Stream) + + $originalPosition = $Stream.Position + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $Stream.Position = 0 + return [BitConverter]::ToString( + $sha256.ComputeHash($Stream) + ).Replace("-", "") + } + finally { + $Stream.Position = $originalPosition + $sha256.Dispose() + } +} + +function Get-NativeUnprotectedFileEvidence { + param([Parameter(Mandatory = $true)][string]$Path) + + $stream = [IO.File]::Open( + $Path, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete + ) + try { + $identity = Get-MinerOpsNativeFileIdentity -Stream $stream + $acl = Get-MinerOpsStreamAcl -Stream $stream + return [pscustomobject]@{ + Identity = [string]$identity.Identity + VolumeSerialNumber = [uint32]$identity.VolumeSerialNumber + FileIndex = [uint64]$identity.FileIndex + NumberOfLinks = [uint32]$identity.NumberOfLinks + OwnerSid = [string]$acl.GetOwner( + [Security.Principal.SecurityIdentifier] + ).Value + AclSddl = [string]$acl.Sddl + ContentHash = Get-NativeStreamSha256 -Stream $stream + } + } + finally { + $stream.Dispose() + } +} + +function Get-NativeProtectedFileEvidence { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedParent + ) + + $content = Read-MinerOpsProtectedCredentialText ` + -Path $Path -ExpectedParent $ExpectedParent + $stream = Open-MinerOpsProtectedCredentialFile ` + -Path $Path -ExpectedParent $ExpectedParent + try { + $identity = Get-MinerOpsNativeFileIdentity -Stream $stream + $acl = Get-MinerOpsStreamAcl -Stream $stream + $ownerSid = [string]$acl.GetOwner( + [Security.Principal.SecurityIdentifier] + ).Value + return [pscustomobject]@{ + Identity = [string]$identity.Identity + VolumeSerialNumber = [uint32]$identity.VolumeSerialNumber + FileIndex = [uint64]$identity.FileIndex + NumberOfLinks = [uint32]$identity.NumberOfLinks + AclTrusted = [bool](Test-MinerOpsCredentialAcl -Acl $acl) + AclSddl = [string]$acl.Sddl + OwnerSid = $ownerSid + OwnerTrusted = $ownerSid -in @("S-1-5-18", "S-1-5-32-544") + ContentHash = Get-NativeStreamSha256 -Stream $stream + } + } + finally { + $stream.Dispose() + } +} + +try { + (New-Object IO.DirectoryInfo($workRoot)).Create( + (New-MinerOpsAgentDirectoryAcl) + ) + [void](Assert-MinerOpsCredentialDirectory -Path $workRoot) + + $driveRootTarget = Join-Path $workRoot "drive-root-target" + (New-Object IO.DirectoryInfo($driveRootTarget)).Create( + (New-MinerOpsAgentDirectoryAcl) + ) + [void](Assert-MinerOpsCredentialDirectory -Path $driveRootTarget) + + $driveRelativeTrap = Join-Path $workRoot "drive-relative-trap" + (New-Object IO.DirectoryInfo($driveRelativeTrap)).Create( + (New-MinerOpsAgentDirectoryAcl) + ) + $trapAcl = Get-Acl -LiteralPath $driveRelativeTrap + $authenticatedUsers = New-Object ` + System.Security.Principal.SecurityIdentifier("S-1-5-11") + $trapAcl.AddAccessRule((New-Object ` + System.Security.AccessControl.FileSystemAccessRule( + $authenticatedUsers, + [System.Security.AccessControl.FileSystemRights]::Modify, + [System.Security.AccessControl.InheritanceFlags]::None, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow + ))) + Set-Acl -LiteralPath $driveRelativeTrap -AclObject $trapAcl + + Invoke-NativeTest "all security enumerators preserve C:\ as the first component" { + $expectedRoot = [IO.Path]::GetPathRoot( + [IO.Path]::GetFullPath($driveRootTarget) + ) + Assert-Native ($expectedRoot -ceq "C:\") ` + "Disposable validation path was not rooted at C:\." + $driveRelativeRoot = $expectedRoot.TrimEnd("\") + Assert-Native ($driveRelativeRoot -ceq "C:") ` + "Drive-relative control value was not C:." + + $validators = @( + @{ Name = "credential reparse"; Action = { + Assert-MinerOpsNoReparseDirectoryPath -Path $driveRootTarget + } }, + @{ Name = "credential ancestors"; Action = { + Assert-MinerOpsTrustedAncestorNamespace -Path $driveRootTarget + } }, + @{ Name = "enrollment reparse"; Action = { + Assert-MinerOpsEnrollmentNoReparsePath -Path $driveRootTarget + } }, + @{ Name = "enrollment ancestors"; Action = { + Assert-MinerOpsEnrollmentTrustedAncestors -Path $driveRootTarget + } }, + @{ Name = "installer reparse"; Action = { + Assert-MinerOpsInstallerNoReparsePath -Path $driveRootTarget + } }, + @{ Name = "installer ancestors"; Action = { + Assert-MinerOpsInstallerTrustedAncestors -Path $driveRootTarget + } } + ) + foreach ($validator in $validators) { + $action = [scriptblock]$validator.Action + $observed = @(Invoke-NativeGetItemTrace -Action $action) + Assert-Native ($observed.Count -gt 0) ` + "$($validator.Name) did not enumerate filesystem components." + Assert-Native ($observed[0] -ceq $expectedRoot) ` + "$($validator.Name) began at '$($observed[0])', not '$expectedRoot'." + Assert-Native ($observed -cnotcontains $driveRelativeRoot) ` + "$($validator.Name) generated drive-relative root '$driveRelativeRoot'." + foreach ($component in $observed) { + Assert-Native ( + [IO.Path]::IsPathRooted($component) -and + [IO.Path]::GetFullPath($component).StartsWith( + $expectedRoot, + [StringComparison]::OrdinalIgnoreCase + ) + ) "$($validator.Name) generated non-root-qualified component '$component'." + } + } + + $directRootChild = Join-Path $expectedRoot ( + "MinerOpsDriveRootValidation-$validationId" + ) + $rootEdgeValidators = @( + @{ Name = "credential reparse root"; Action = { + Assert-MinerOpsNoReparseDirectoryPath -Path $expectedRoot + } }, + @{ Name = "credential direct-root ancestor"; Action = { + Assert-MinerOpsTrustedAncestorNamespace -Path $directRootChild + } }, + @{ Name = "enrollment reparse root"; Action = { + Assert-MinerOpsEnrollmentNoReparsePath -Path $expectedRoot + } }, + @{ Name = "enrollment direct-root ancestor"; Action = { + Assert-MinerOpsEnrollmentTrustedAncestors -Path $directRootChild + } }, + @{ Name = "installer reparse root"; Action = { + Assert-MinerOpsInstallerNoReparsePath -Path $expectedRoot + } }, + @{ Name = "installer direct-root ancestor"; Action = { + Assert-MinerOpsInstallerTrustedAncestors -Path $directRootChild + } } + ) + foreach ($validator in $rootEdgeValidators) { + $action = [scriptblock]$validator.Action + $observed = @(Invoke-NativeGetItemTrace -Action $action) + Assert-Native ($observed.Count -eq 1) ` + "$($validator.Name) did not enumerate exactly one root component." + Assert-Native ($observed[0] -ceq $expectedRoot) ` + "$($validator.Name) enumerated '$($observed[0])', not '$expectedRoot'." + Assert-Native ($observed -cnotcontains $driveRelativeRoot) ` + "$($validator.Name) generated drive-relative root '$driveRelativeRoot'." + } + Assert-MinerOpsNativeGetItemState -Expected $originalGetItemState ` + -Context "root-qualified validator traces" + } + + Invoke-NativeTest "absolute validation ignores a hostile PowerShell location" { + $trapRules = @((Get-Acl -LiteralPath $driveRelativeTrap).GetAccessRules( + $true, + $true, + [System.Security.Principal.SecurityIdentifier] + )) + $trapRule = @($trapRules | Where-Object { + $_.IdentityReference.Value -eq "S-1-5-11" -and + ($_.FileSystemRights -band + [System.Security.AccessControl.FileSystemRights]::Delete) -ne 0 + }) + Assert-Native ($trapRule.Count -gt 0) ` + "Drive-relative trap does not grant Authenticated Users Delete rights." + $before = (Get-Location).Path + try { + Set-Location -LiteralPath $driveRelativeTrap + Assert-AllNativeRootValidators -Path $driveRootTarget + } + finally { + Set-Location -LiteralPath $before + } + } + + Invoke-NativeTest "absolute validation ignores process current directory" { + $beforeLocation = (Get-Location).Path + $beforeProcessDirectory = [Environment]::CurrentDirectory + try { + Set-Location -LiteralPath "C:\" + [Environment]::CurrentDirectory = $driveRelativeTrap + Assert-AllNativeRootValidators -Path $driveRootTarget + } + finally { + [Environment]::CurrentDirectory = $beforeProcessDirectory + Set-Location -LiteralPath $beforeLocation + } + } + + Invoke-NativeTest "untrusted ancestor namespace still fails closed" { + $untrustedAncestor = Join-Path $workRoot "untrusted-ancestor" + (New-Object IO.DirectoryInfo($untrustedAncestor)).Create( + (New-MinerOpsAgentDirectoryAcl) + ) + $ancestorAcl = Get-Acl -LiteralPath $untrustedAncestor + $ancestorAcl.AddAccessRule((New-Object ` + System.Security.AccessControl.FileSystemAccessRule( + $authenticatedUsers, + [System.Security.AccessControl.FileSystemRights]::Modify, + [System.Security.AccessControl.InheritanceFlags]::None, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow + ))) + Set-Acl -LiteralPath $untrustedAncestor -AclObject $ancestorAcl + $untrustedChild = Join-Path $untrustedAncestor "child" + (New-Object IO.DirectoryInfo($untrustedChild)).Create( + (New-MinerOpsAgentDirectoryAcl) + ) + + foreach ($validator in @( + { Assert-MinerOpsTrustedAncestorNamespace -Path $untrustedChild }, + { Assert-MinerOpsEnrollmentTrustedAncestors -Path $untrustedChild }, + { Assert-MinerOpsInstallerTrustedAncestors -Path $untrustedChild } + )) { + try { + & $validator + throw "Untrusted ancestor was accepted." + } + catch { + Assert-Native ( + $_.Exception.Message -match "untrusted namespace replacement" + ) "Untrusted ancestor produced an unexpected error." + } + } + } + + $passwordPlain = "M0ps!" + [guid]::NewGuid().ToString("N") + "z9" + $password = ConvertTo-SecureString $passwordPlain -AsPlainText -Force + $passwordPlain = $null + New-LocalUser ` + -Name $userName ` + -Password $password ` + -PasswordNeverExpires ` + -UserMayNotChangePassword | Out-Null + $usersGroup = Get-LocalGroup -SID "S-1-5-32-545" + Add-LocalGroupMember -Group $usersGroup.Name -Member $userName + $testUser = New-Object Management.Automation.PSCredential( + "$env:COMPUTERNAME\$userName", + $password + ) + + Copy-Item -LiteralPath $standardUserProbeScript ` + -Destination $readerPath -Force + Copy-Item -LiteralPath $harnessSupportScript ` + -Destination $readerSupportPath -Force + + Invoke-NativeTest "protected CreateNew applies and reads back ACL before content" { + $path = Join-Path $workRoot "protected-before-content.json" + $script:nativeAclReadbackSawEmptyFile = $false + $script:nativeOriginalGetStreamAcl = + (Get-Command Get-MinerOpsStreamAcl).ScriptBlock + try { + Set-Item Function:\Get-MinerOpsStreamAcl { + param($Stream) + Assert-Native ($Stream.Length -eq 0) ` + "ACL read-back occurred after content was written." + $script:nativeAclReadbackSawEmptyFile = $true + & $script:nativeOriginalGetStreamAcl -Stream $Stream + } + Write-MinerOpsProtectedCredentialJson ` + -Path $path ` + -ExpectedParent $workRoot ` + -Value @{ agent_secret = "protected-after-verification" } + } + finally { + Set-Item Function:\Get-MinerOpsStreamAcl ` + $script:nativeOriginalGetStreamAcl + } + Assert-Native $script:nativeAclReadbackSawEmptyFile ` + "Protected creation did not read the ACL from the empty open file." + $content = Read-MinerOpsProtectedCredentialText ` + -Path $path -ExpectedParent $workRoot + Assert-Native ($content -match "protected-after-verification") ` + "Protected credential content was not durably written." + } + + Invoke-NativeTest "enrollment and installer protected CreateNew mirrors work natively" { + $implementations = @( + @{ + Name = "enrollment" + NewAcl = { New-MinerOpsEnrollmentCredentialAcl } + Create = { + param($Path, $Acl) + New-MinerOpsEnrollmentProtectedFileCreateNewStream ` + -Path $Path -Acl $Acl + } + GetAcl = { + param($Stream) + Get-MinerOpsEnrollmentStreamAcl -Stream $Stream + } + TestAcl = { + param($Acl) + Test-MinerOpsEnrollmentCredentialAcl -Acl $Acl + } + }, + @{ + Name = "installer" + NewAcl = { New-MinerOpsInstallerProtectedFileAcl } + Create = { + param($Path, $Acl) + New-MinerOpsInstallerProtectedFileCreateNewStream ` + -Path $Path -Acl $Acl + } + GetAcl = { + param($Stream) + Get-MinerOpsInstallerStreamAcl -Stream $Stream + } + TestAcl = { + param($Acl) + Test-MinerOpsInstallerProtectedFileAcl -Acl $Acl + } + } + ) + foreach ($implementation in $implementations) { + $path = Join-Path $workRoot ( + "{0}-protected-create.state" -f $implementation.Name + ) + $stream = $null + $competing = $null + try { + $acl = & $implementation.NewAcl + $stream = & $implementation.Create $path $acl + Assert-Native ($stream.Length -eq 0) ` + "$($implementation.Name) wrote before ACL verification." + $readBack = & $implementation.GetAcl $stream + Assert-Native ([bool](& $implementation.TestAcl $readBack)) ` + "$($implementation.Name) ACL read-back failed." + try { + $competing = [IO.File]::Open( + $path, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::ReadWrite + ) + throw "$($implementation.Name) protected file was not exclusive." + } + catch [IO.IOException] { } + } + finally { + if ($null -ne $competing) { $competing.Dispose() } + if ($null -ne $stream) { $stream.Dispose() } + } + } + } + + Invoke-NativeTest "recovery and pending DACLs deny a standard user" { + foreach ($leaf in @("recovery.json", "agent-config.json.pending")) { + $path = Join-Path $workRoot $leaf + Write-MinerOpsProtectedCredentialJson ` + -Path $path ` + -ExpectedParent $workRoot ` + -Value ([ordered]@{ + agent_id = "00000000-0000-4000-8000-000000000111" + agent_secret = "disposable-validation-secret" + }) + Assert-StandardUserDenied -Operation read -Path $path + } + } + + Invoke-NativeTest "directory DACL denies standard-user child creation" { + $untrustedChild = Join-Path $workRoot "standard-user-create.json" + Assert-StandardUserDenied -Operation create -Path $untrustedChild + Assert-Native (-not (Test-Path -LiteralPath $untrustedChild)) ` + "Denied standard-user create left a child file." + } + + Invoke-NativeTest "file DACL denies standard-user modify, delete, and rename" { + $protectedPath = Join-Path $workRoot "delete-rename-source.json" + $renamedPath = Join-Path $workRoot "delete-rename-destination.json" + Write-MinerOpsProtectedCredentialJson ` + -Path $protectedPath -ExpectedParent $workRoot ` + -Value @{ agent_secret = "disposable-delete-test" } + Assert-StandardUserDenied -Operation modify -Path $protectedPath + Assert-Native ( + [IO.File]::ReadAllText($protectedPath) -match + "disposable-delete-test" + ) "Denied standard-user modify changed the file." + Assert-StandardUserDenied -Operation delete -Path $protectedPath + Assert-Native (Test-Path -LiteralPath $protectedPath -PathType Leaf) ` + "Denied standard-user delete removed the file." + Assert-StandardUserDenied -Operation rename -Path $protectedPath ` + -Destination $renamedPath + Assert-Native ( + (Test-Path -LiteralPath $protectedPath -PathType Leaf) -and + -not (Test-Path -LiteralPath $renamedPath) + ) "Denied standard-user rename changed the namespace." + } + + Invoke-NativeTest "standard-user replacement swap is denied" { + $activePath = Join-Path $workRoot "swap-active.json" + $replacementPath = Join-Path $env:PUBLIC ( + "minerops-swap-source-$validationId.json" + ) + $renamedTargetPath = Join-Path $workRoot "swap-active-renamed.json" + try { + Assert-Native (-not (Test-Path -LiteralPath $replacementPath)) ` + "Swap source unexpectedly existed before standard-user creation." + $prepareResult = Invoke-StandardUserOperation ` + -Operation prepare_swap -Path $replacementPath + Assert-Native ( + [string]$prepareResult.status -ceq "succeeded" -and + [bool]$prepareResult.source_exists -and + [bool]$prepareResult.input_owned_by_caller -and + [bool]$prepareResult.source_created_by_caller -and + [bool]$prepareResult.source_read_verified -and + [bool]$prepareResult.source_modify_verified -and + [bool]$prepareResult.source_rename_verified -and + [bool]$prepareResult.source_delete_access_verified -and + [bool]$prepareResult.source_replace_access_verified + ) "Standard user did not create and control the swap source." + $sourceBefore = Get-NativeUnprotectedFileEvidence ` + -Path $replacementPath + + Write-MinerOpsProtectedCredentialJson ` + -Path $activePath -ExpectedParent $workRoot ` + -Value @{ agent_secret = "protected-active" } + $targetBefore = Get-NativeProtectedFileEvidence ` + -Path $activePath -ExpectedParent $workRoot + + $sourceFullPath = [IO.Path]::GetFullPath($replacementPath) + $targetFullPath = [IO.Path]::GetFullPath($activePath) + $sourceDrive = [IO.DriveInfo]::new( + [IO.Path]::GetPathRoot($sourceFullPath) + ) + $targetDrive = [IO.DriveInfo]::new( + [IO.Path]::GetPathRoot($targetFullPath) + ) + $standardUserSid = [string]( + Get-LocalUser -Name $userName -ErrorAction Stop + ).SID.Value + $fixture = [ordered]@{ + SourceExists = Test-Path ` + -LiteralPath $sourceFullPath -PathType Leaf + TargetExists = Test-Path ` + -LiteralPath $targetFullPath -PathType Leaf + PathsDistinct = -not $sourceFullPath.Equals( + $targetFullPath, + [StringComparison]::OrdinalIgnoreCase + ) + SourceCreatedByStandardUser = + [bool]$prepareResult.source_created_by_caller + SourceOwnedByStandardUser = + [bool]$prepareResult.input_owned_by_caller -and + $sourceBefore.OwnerSid -ceq $standardUserSid + SourceReadable = [bool]$prepareResult.source_read_verified + SourceModifiable = [bool]$prepareResult.source_modify_verified + SourceRenameable = [bool]$prepareResult.source_rename_verified + SourceDeleteCapable = + [bool]$prepareResult.source_delete_access_verified + SourceReplaceAccessVerified = + [bool]$prepareResult.source_replace_access_verified + TargetAclTrusted = [bool]$targetBefore.AclTrusted + TargetOwnerTrusted = [bool]$targetBefore.OwnerTrusted + TargetIdentityCaptured = + -not [string]::IsNullOrWhiteSpace($targetBefore.Identity) + SourceIdentityCaptured = + -not [string]::IsNullOrWhiteSpace($sourceBefore.Identity) + FileIdentitiesDistinct = + $sourceBefore.Identity -cne $targetBefore.Identity + # No backup is deliberately selected. It avoids copying the + # protected destination into an attacker-readable namespace. + BackupArgumentValid = $true + BackupPathsDistinct = $true + BackupExpectedStateVerified = $true + BackupConfidentialitySafe = $true + SameVolume = $sourceBefore.VolumeSerialNumber -eq + $targetBefore.VolumeSerialNumber + NtfsVolume = $sourceDrive.DriveFormat -ceq "NTFS" -and + $targetDrive.DriveFormat -ceq "NTFS" + } + Assert-MinerOpsNativeSwapFixtureReady -Fixture $fixture + + $swapResult = Invoke-StandardUserOperation ` + -Operation swap -Path $replacementPath ` + -Destination $activePath -BackupMode None + Assert-Native ( + [string]$swapResult.input_full_path -ceq $sourceFullPath -and + [string]$swapResult.destination_full_path -ceq $targetFullPath -and + [string]$swapResult.backup_mode -ceq "None" -and + [string]::IsNullOrEmpty( + [string]$swapResult.backup_full_path + ) -and + [bool]$swapResult.backup_argument_was_native_null -and + [string]$swapResult.replace_overload -ceq + "System.IO.File.Replace(String,String,String)" -and + [bool]$swapResult.source_existed_before_attack -and + [bool]$swapResult.destination_existed_before_attack -and + $null -eq $swapResult.backup_existed_before_attack + ) "Standard-user swap did not use the verified File.Replace arguments." + $postconditions = [ordered]@{ + TargetExists = Test-Path -LiteralPath $activePath -PathType Leaf + TargetIdentityUnchanged = $false + TargetContentUnchanged = $false + TargetAclTrusted = $false + TargetAclUnchanged = $false + TargetOwnerTrusted = $false + TargetOwnerUnchanged = $false + TargetSingleLink = $false + SourceExists = Test-Path ` + -LiteralPath $replacementPath -PathType Leaf + SourceIdentityUnchanged = $false + SourceContentUnchanged = $false + AttackerSourceNotPromoted = $false + TargetDeniedStandardUserRead = $false + TargetDeniedStandardUserModify = $false + TargetDeniedStandardUserDelete = $false + TargetDeniedStandardUserRename = $false + NoAttackerReadableBackup = + [bool]$swapResult.backup_argument_was_native_null -and + [string]::IsNullOrEmpty( + [string]$swapResult.backup_full_path + ) + } + + $readResult = Invoke-StandardUserOperation ` + -Operation read -Path $activePath + $postconditions.TargetDeniedStandardUserRead = + [string]$readResult.status -ceq "security_denied" + $modifyResult = Invoke-StandardUserOperation ` + -Operation modify -Path $activePath + $postconditions.TargetDeniedStandardUserModify = + [string]$modifyResult.status -ceq "security_denied" + $deleteResult = Invoke-StandardUserOperation ` + -Operation delete -Path $activePath + $postconditions.TargetDeniedStandardUserDelete = + [string]$deleteResult.status -ceq "security_denied" + $renameResult = Invoke-StandardUserOperation ` + -Operation rename -Path $activePath ` + -Destination $renamedTargetPath + $postconditions.TargetDeniedStandardUserRename = + [string]$renameResult.status -ceq "security_denied" + + $postconditions.TargetExists = Test-Path ` + -LiteralPath $activePath -PathType Leaf + if ([bool]$postconditions.TargetExists) { + try { + $targetAfter = Get-NativeProtectedFileEvidence ` + -Path $activePath -ExpectedParent $workRoot + $postconditions.TargetIdentityUnchanged = + $targetAfter.Identity -ceq $targetBefore.Identity + $postconditions.TargetContentUnchanged = + $targetAfter.ContentHash -ceq $targetBefore.ContentHash + $postconditions.TargetAclTrusted = $targetAfter.AclTrusted + $postconditions.TargetAclUnchanged = + $targetAfter.AclSddl -ceq $targetBefore.AclSddl + $postconditions.TargetOwnerTrusted = + [bool]$targetAfter.OwnerTrusted + $postconditions.TargetOwnerUnchanged = + $targetAfter.OwnerSid -ceq $targetBefore.OwnerSid + $postconditions.TargetSingleLink = + $targetAfter.NumberOfLinks -eq 1 + $postconditions.AttackerSourceNotPromoted = + $targetAfter.Identity -cne $sourceBefore.Identity -and + $targetAfter.ContentHash -cne $sourceBefore.ContentHash + } + catch { + # The consolidated assertion below reports each failed + # target-security postcondition without exposing content. + } + } + + $postconditions.SourceExists = Test-Path ` + -LiteralPath $replacementPath -PathType Leaf + if ([bool]$postconditions.SourceExists) { + try { + $sourceAfter = Get-NativeUnprotectedFileEvidence ` + -Path $replacementPath + $postconditions.SourceIdentityUnchanged = + $sourceAfter.Identity -ceq $sourceBefore.Identity + $postconditions.SourceContentUnchanged = + $sourceAfter.ContentHash -ceq $sourceBefore.ContentHash + } + catch { } + } + + Assert-MinerOpsNativeSwapSecurityOutcome ` + -Result $swapResult -Postconditions $postconditions + } + finally { + Remove-Item -LiteralPath $replacementPath -Force ` + -ErrorAction SilentlyContinue + } + } + + Invoke-NativeTest "ACL setup and verification failures write no secret" { + $setupFailurePath = Join-Path $workRoot "acl-setup-failure.json" + $originalNewAcl = (Get-Command New-MinerOpsCredentialAcl).ScriptBlock + try { + Set-Item Function:\New-MinerOpsCredentialAcl { + throw "injected native ACL setup failure" + } + try { + Write-MinerOpsProtectedCredentialJson ` + -Path $setupFailurePath ` + -ExpectedParent $workRoot ` + -Value @{ agent_secret = "must-not-appear" } + throw "ACL setup injection unexpectedly succeeded." + } + catch { + Assert-Native ($_.Exception.Message -match "ACL setup failure") ` + "Unexpected ACL setup injection error." + } + } + finally { + Set-Item Function:\New-MinerOpsCredentialAcl $originalNewAcl + } + Assert-Native (-not (Test-Path -LiteralPath $setupFailurePath)) ` + "ACL setup failure left a credential file." + + $verificationFailurePath = Join-Path $workRoot ( + "acl-verification-failure.json" + ) + $originalGetAcl = (Get-Command Get-MinerOpsStreamAcl).ScriptBlock + try { + Set-Item Function:\Get-MinerOpsStreamAcl { + param($Stream) + throw "injected native ACL verification failure" + } + try { + Write-MinerOpsProtectedCredentialJson ` + -Path $verificationFailurePath ` + -ExpectedParent $workRoot ` + -Value @{ agent_secret = "must-not-appear" } + throw "ACL verification injection unexpectedly succeeded." + } + catch { + Assert-Native ( + $_.Exception.Message -match "ACL verification failure" + ) "Unexpected ACL verification injection error." + } + } + finally { + Set-Item Function:\Get-MinerOpsStreamAcl $originalGetAcl + } + Assert-Native (-not (Test-Path -LiteralPath $verificationFailurePath)) ` + "ACL verification failure left a credential file." + } + + Invoke-NativeTest "pre-created credential pathname is never truncated" { + $path = Join-Path $workRoot "pre-created-pending.json" + $sentinel = '{"attacker_controlled":"preserve-exactly"}' + [IO.File]::WriteAllText($path, $sentinel) + try { + Write-MinerOpsProtectedCredentialJson ` + -Path $path -ExpectedParent $workRoot ` + -Value @{ agent_secret = "must-not-be-written" } + throw "Pre-created pathname was adopted." + } + catch { + Assert-Native ($_.Exception.Message -match "already exists|exist") ` + "Pre-created pathname produced an unexpected error." + } + Assert-Native ([IO.File]::ReadAllText($path) -ceq $sentinel) ` + "CreateNew failure modified the pre-created pathname." + } + + Invoke-NativeTest "attacker-created hard-linked credential target is rejected" { + $sourcePath = Join-Path $workRoot "hard-link-source.json" + $targetPath = Join-Path $workRoot "hard-link-pending.json" + Write-MinerOpsProtectedCredentialJson ` + -Path $sourcePath -ExpectedParent $workRoot ` + -Value @{ harmless = "preexisting" } + [void](New-Item -ItemType HardLink -Path $targetPath ` + -Target $sourcePath) + try { + Write-MinerOpsProtectedCredentialJson ` + -Path $targetPath -ExpectedParent $workRoot ` + -Value @{ agent_secret = "must-not-be-written" } + throw "Hard-linked CreateNew target was adopted." + } + catch { + Assert-Native ( + $_.Exception.Message -match "already exists|exist" + ) "Hard-linked target produced an unexpected error." + } + Assert-Native ( + (Test-Path -LiteralPath $sourcePath -PathType Leaf) -and + (Test-Path -LiteralPath $targetPath -PathType Leaf) + ) "Failed CreateNew deleted hard-link evidence." + Assert-Native ( + -not ([IO.File]::ReadAllText($sourcePath).Contains( + "must-not-be-written" + )) + ) "Credential content was written through a hard-link alias." + } + + Invoke-NativeTest "insecure existing artifact is repaired then rejected" { + $insecurePath = Join-Path $workRoot "insecure-recovery.json" + [IO.File]::WriteAllText($insecurePath, '{"agent_secret":"untrusted"}') + $script:nativeRepairPath = $insecurePath + $script:nativeRepairWasExclusive = $false + $script:nativeOriginalSetStreamAcl = + (Get-Command Set-MinerOpsStreamAcl).ScriptBlock + try { + Set-Item Function:\Set-MinerOpsStreamAcl { + param($Stream, $Acl) + $competing = $null + try { + $competing = [IO.File]::Open( + $script:nativeRepairPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::ReadWrite + ) + throw "Credential repair handle was not exclusive." + } + catch [IO.IOException] { + $script:nativeRepairWasExclusive = $true + } + finally { + if ($null -ne $competing) { $competing.Dispose() } + } + & $script:nativeOriginalSetStreamAcl ` + -Stream $Stream -Acl $Acl + } + try { + [void](Read-MinerOpsProtectedCredentialText ` + -Path $insecurePath -ExpectedParent $workRoot) + throw "Insecure artifact was trusted." + } + catch { + Assert-Native ($_.Exception.Message -match "will not be trusted") ` + "Insecure artifact did not fail closed." + } + } + finally { + Set-Item Function:\Set-MinerOpsStreamAcl ` + $script:nativeOriginalSetStreamAcl + } + Assert-Native $script:nativeRepairWasExclusive ` + "Existing-artifact repair was not exclusive." + Assert-StandardUserDenied -Operation read -Path $insecurePath + } + + Invoke-NativeTest "repair fails closed when security rights cannot be acquired" { + $path = Join-Path $workRoot "repair-rights-denied.json" + [IO.File]::WriteAllText($path, '{"agent_secret":"untrusted"}') + $beforeSddl = (Get-Acl -LiteralPath $path).Sddl + $originalOpen = + (Get-Command Open-MinerOpsFileStreamWithRights).ScriptBlock + try { + Set-Item Function:\Open-MinerOpsFileStreamWithRights { + param($Path, $Rights) + throw [System.UnauthorizedAccessException]::new( + "injected security-right acquisition failure" + ) + } + try { + [void](Read-MinerOpsProtectedCredentialText ` + -Path $path -ExpectedParent $workRoot) + throw "Security-right acquisition failure was ignored." + } + catch { + Assert-Native ( + $_.Exception.Message -match "right acquisition failure" + ) "Security-right acquisition failure did not propagate." + } + } + finally { + Set-Item Function:\Open-MinerOpsFileStreamWithRights $originalOpen + } + Assert-Native ((Get-Acl -LiteralPath $path).Sddl -ceq $beforeSddl) ` + "Denied repair used a fallback ACL mutation." + Assert-Native ( + [IO.File]::ReadAllText($path) -ceq '{"agent_secret":"untrusted"}' + ) "Denied repair changed credential content." + } + + Invoke-NativeTest "durable blocker prevents ACL-repair laundering" { + $recoveryPath = Join-Path $workRoot "agent-credential-transition.recovery.json" + [IO.File]::WriteAllText( + $recoveryPath, + '{"agent_secret":"must-never-be-authoritative"}' + ) + $blocker = Ensure-MinerOpsQuarantineBlocker ` + -TargetRoot $workRoot -Reason "native-acl-repair-test" + $opened = Open-MinerOpsProtectedCredentialFile ` + -Path $recoveryPath -ExpectedParent $workRoot -AllowAclRepair + $opened.Dispose() + + # This is the exact interrupted window: ACL repaired, authority name + # still present, and the process could terminate before quarantine. + Assert-Native (Test-Path -LiteralPath $recoveryPath -PathType Leaf) ` + "ACL-repair window was not established." + Assert-Native (Test-Path -LiteralPath $blocker -PathType Leaf) ` + "Blocked-state marker was not durable before ACL repair." + try { + Assert-MinerOpsNoQuarantineBlocker -TargetRoot $workRoot + throw "Durable blocker did not fail the second invocation closed." + } + catch { + Assert-Native ($_.Exception.Message -match "evidence is quarantined") ` + "Durable blocker produced an unexpected error." + } + [void](Move-MinerOpsArtifactToQuarantine ` + -Path $recoveryPath -ExpectedParent $workRoot -Role "recovery") + Assert-Native (-not (Test-Path -LiteralPath $recoveryPath)) ` + "Repaired recovery remained authoritative." + try { + Assert-MinerOpsNoQuarantineBlocker -TargetRoot $workRoot + throw "Quarantine evidence did not keep the next run blocked." + } + catch { + Assert-Native ($_.Exception.Message -match "evidence is quarantined") ` + "Quarantine evidence produced an unexpected error." + } + foreach ($evidence in @(Get-MinerOpsQuarantineArtifacts ` + -TargetRoot $workRoot)) { + Assert-StandardUserDenied -Operation read -Path $evidence.FullName + } + } + + Invoke-NativeTest "native two-rename install preserves protected result" { + $activePath = Join-Path $workRoot "replace-active.json" + $pendingPath = Join-Path $workRoot "replace-pending.json" + $priorPath = Join-Path $workRoot "replace-prior.json" + Write-MinerOpsProtectedCredentialJson ` + -Path $activePath -ExpectedParent $workRoot ` + -Value @{ agent_secret = "legacy-test-secret" } + Write-MinerOpsProtectedCredentialJson ` + -Path $pendingPath -ExpectedParent $workRoot ` + -Value @{ agent_secret = "new-test-secret" } + Invoke-MinerOpsRecoverableConfigInstall ` + -PendingPath $pendingPath -ConfigPath $activePath ` + -PriorPath $priorPath -TargetRoot $workRoot + Assert-Native (-not (Test-Path -LiteralPath $pendingPath)) ` + "Two-rename install did not consume pending source." + Assert-Native (Test-Path -LiteralPath $priorPath -PathType Leaf) ` + "Two-rename install did not retain the prior remnant." + $result = Read-MinerOpsProtectedCredentialText ` + -Path $activePath -ExpectedParent $workRoot + Assert-Native ($result -match "new-test-secret") ` + "Two-rename install did not install the pending content." + Assert-StandardUserDenied -Operation read -Path $activePath + } + + Invoke-NativeTest "active-missing pending install converges natively" { + $activePath = Join-Path $workRoot "missing-active.json" + $pendingPath = Join-Path $workRoot "missing-active.pending" + $priorPath = Join-Path $workRoot "missing-active.prior" + Write-MinerOpsProtectedCredentialJson ` + -Path $pendingPath -ExpectedParent $workRoot ` + -Value @{ agent_secret = "authenticated-pending" } + Invoke-MinerOpsRecoverableConfigInstall ` + -PendingPath $pendingPath -ConfigPath $activePath ` + -PriorPath $priorPath -TargetRoot $workRoot + Assert-Native (Test-Path -LiteralPath $activePath -PathType Leaf) ` + "Active-missing install did not restore active." + Assert-Native (-not (Test-Path -LiteralPath $priorPath)) ` + "Active-missing install manufactured prior authority." + } + + Invoke-NativeTest "directory junction is rejected by all reparse validators" { + $junctionTarget = Join-Path $workRoot "junction-target" + $junctionPath = Join-Path $workRoot "junction-path" + [void](New-Item -ItemType Directory -Path $junctionTarget) + [void](New-Item -ItemType Junction -Path $junctionPath ` + -Target $junctionTarget) + foreach ($validator in @( + { Assert-MinerOpsNoReparseDirectoryPath -Path $junctionPath }, + { Assert-MinerOpsEnrollmentNoReparsePath -Path $junctionPath }, + { Assert-MinerOpsInstallerNoReparsePath -Path $junctionPath } + )) { + try { + [void](& $validator) + throw "Directory junction was accepted." + } + catch { + Assert-Native ($_.Exception.Message -match "reparse point") ` + "Directory junction produced an unexpected error." + } + } + } + + Invoke-NativeTest "permissive directory is hardened with a durable repair marker" { + $permissivePath = Join-Path $workRoot "permissive-directory" + [void](New-Item -ItemType Directory -Path $permissivePath) + $acl = Get-Acl -LiteralPath $permissivePath + $users = New-Object System.Security.Principal.SecurityIdentifier( + "S-1-5-32-545" + ) + $acl.AddAccessRule((New-Object ` + System.Security.AccessControl.FileSystemAccessRule( + $users, + [System.Security.AccessControl.FileSystemRights]::Modify, + [System.Security.AccessControl.InheritanceFlags]"ContainerInherit, ObjectInherit", + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow + ))) + Set-Acl -LiteralPath $permissivePath -AclObject $acl + $state = Initialize-MinerOpsCredentialDirectory -Path $permissivePath + Assert-Native ([bool]$state.WasRepaired) ` + "Permissive directory was not identified as repaired." + [void](Assert-MinerOpsCredentialDirectory -Path $permissivePath) + $blockers = @(Get-MinerOpsFilesystemTrustBlockerPaths ` + -Path $permissivePath) + Assert-Native ($blockers.Count -gt 0) ` + "External directory trust blocker was not durable." + foreach ($blocker in $blockers) { + $record = Read-MinerOpsFilesystemTrustBlocker ` + -Path $blocker.FullName -TargetRoot $permissivePath + Assert-Native ([string]$record.state -ceq "blocked") ` + "Filesystem trust blocker ACL/content verification failed." + } + Assert-Native ([bool]$state.TrustEpochNewlyBlocked) ` + "Directory repair did not force a new blocked trust epoch." + } + + $serviceScript = Join-Path $workRoot "MinerOpsAgentService.ps1" + [IO.File]::WriteAllText($serviceScript, "Start-Sleep -Seconds 600`r`n") + $action = New-ScheduledTaskAction ` + -Execute "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" ` + -Argument ('-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f ` + $serviceScript) + $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" ` + -LogonType ServiceAccount -RunLevel Highest + Register-ScheduledTask ` + -TaskName $taskName ` + -Action $action ` + -Principal $principal | Out-Null + $taskCreated = $true + + foreach ($originallyRunning in @($true, $false)) { + $label = if ($originallyRunning) { "running" } else { "stopped" } + Invoke-NativeTest "native Scheduled Task restores originally $label intent" { + Set-MinerOpsScheduledTaskState ` + -Name $taskName -TargetRoot $workRoot ` + -ShouldRun $originallyRunning + $statePath = Join-Path $workRoot "task-state-$label.json" + Write-MinerOpsProtectedCredentialJson ` + -Path $statePath ` + -ExpectedParent $workRoot ` + -Value @{ original_task_running = $originallyRunning } + Stop-MinerOpsAgentTask -Name $taskName -TargetRoot $workRoot + $state = Read-MinerOpsProtectedCredentialText ` + -Path $statePath -ExpectedParent $workRoot | ConvertFrom-Json + Set-MinerOpsScheduledTaskState ` + -Name $taskName ` + -TargetRoot $workRoot ` + -ShouldRun ([bool]$state.original_task_running) + $final = Get-MinerOpsVerifiedScheduledTask ` + -Name $taskName -TargetRoot $workRoot + Assert-Native ( + ([string]$final.State -eq "Running") -eq $originallyRunning -and + ([string]$final.State -eq "Ready") -eq (-not $originallyRunning) + ) "Native Scheduled Task final state was wrong." + } + } + + Invoke-NativeTest "strict verifier rejects missing and Disabled tasks" { + try { + [void](Get-MinerOpsVerifiedScheduledTask ` + -Name "$taskName-missing" -TargetRoot $workRoot) + throw "Missing task was accepted." + } + catch { + Assert-Native ($_.Exception.Message -match "missing") ` + "Missing task produced an unexpected error." + } + Disable-ScheduledTask -TaskName $taskName | Out-Null + try { + [void](Get-MinerOpsVerifiedScheduledTask ` + -Name $taskName -TargetRoot $workRoot) + throw "Disabled task was accepted." + } + catch { + Assert-Native ($_.Exception.Message -match "disabled") ` + "Disabled task produced an unexpected error." + } + Enable-ScheduledTask -TaskName $taskName | Out-Null + } + + Write-Host "$passed native Windows credential-transition tests passed." + Write-Host "This proves root-qualified drive enumeration, current-directory independence, creation-time protected FileStreams, same-handle ACL read-back, native NTFS boundary enforcement, fail-closed ACL repair, durable blocking, hard-link refusal, two-rename primitives, junction rejection, and strict Scheduled Task state restoration." + Write-Host "Run the companion validation procedure for hostile pre-repair handles, installer/enrollment hard links, full strict-task failures, and hard process-termination/rerun convergence." +} +finally { + [Environment]::CurrentDirectory = $originalProcessCurrentDirectory + Set-Location -LiteralPath $originalPowerShellLocation ` + -ErrorAction SilentlyContinue + if ($taskCreated) { + Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + Unregister-ScheduledTask ` + -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + } + if (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue) { + Remove-LocalUser -Name $userName -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $readerPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $readerSupportPath -Force ` + -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $publicResult -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $workRoot -Recurse -Force -ErrorAction SilentlyContinue + Assert-MinerOpsNativeGetItemState -Expected $originalGetItemState ` + -Context "native harness final cleanup" +} diff --git a/agents/windows/tests/Test-MinerOpsCredentialTransition.ps1 b/agents/windows/tests/Test-MinerOpsCredentialTransition.ps1 new file mode 100644 index 0000000..68fcaa9 --- /dev/null +++ b/agents/windows/tests/Test-MinerOpsCredentialTransition.ps1 @@ -0,0 +1,2015 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path $PSScriptRoot -Parent +$scriptPath = Join-Path $root "Upgrade-MinerOpsAgentCredential.ps1" +$tokens = $null +$parseErrors = $null + +[System.Management.Automation.Language.Parser]::ParseFile( + $scriptPath, + [ref]$tokens, + [ref]$parseErrors +) | Out-Null + +if ($parseErrors.Count -gt 0) { + throw "Credential transition parser errors: $($parseErrors.Message -join '; ')" +} + +$source = Get-Content -LiteralPath $scriptPath -Raw + +function Assert-True { + param( + [Parameter(Mandatory = $true)][bool]$Condition, + [Parameter(Mandatory = $true)][string]$Message + ) + if (-not $Condition) { throw $Message } +} + +function Assert-SourceContains { + param( + [Parameter(Mandatory = $true)][string]$Pattern, + [Parameter(Mandatory = $true)][string]$Message + ) + if ($source -notmatch $Pattern) { throw $Message } +} + +function Assert-Throws { + param( + [Parameter(Mandatory = $true)][scriptblock]$Action, + [string]$Pattern = "" + ) + try { + & $Action | Out-Null + } + catch { + if ($Pattern -and $_.Exception.Message -notmatch $Pattern) { + throw "Expected error '$Pattern', got '$($_.Exception.Message)'." + } + return + } + throw "Expected action to fail." +} + +$script:Passed = 0 +function Invoke-BehaviorTest { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][scriptblock]$Action + ) + + & $Action + $script:Passed++ + Write-Host "PASS: $Name" +} + +# Dot-sourcing loads functions without executing the real transition. +. $scriptPath + +function Get-SimPathKey { + param([Parameter(Mandatory = $true)][string]$Path) + return [IO.Path]::GetFullPath($Path) +} + +function New-TransitionSimulation { + param([Parameter(Mandatory = $true)][bool]$OriginallyRunning) + + $testRoot = Join-Path ([IO.Path]::GetTempPath()) ( + "minerops-transition-test-" + [guid]::NewGuid().ToString("N") + ) + $targetRoot = Join-Path $testRoot "agent" + $packageRoot = Join-Path $testRoot "package" + [void](New-Item -ItemType Directory -Path $targetRoot, $packageRoot) + + foreach ($name in $RequiredFiles) { + [IO.File]::WriteAllText((Join-Path $packageRoot $name), "test`n") + } + + $config = [ordered]@{ + hub_url = "https://hub.example.test" + agent_id = "00000000-0000-4000-8000-000000000111" + site_id = "00000000-0000-4000-8000-000000000222" + agent_zone_id = "00000000-0000-4000-8000-000000000333" + site_name = "Test Site" + agent_secret = "legacy-shared-secret" + api3_account = "test-account" + api3_password = "test-password" + poll_seconds = 30 + } + $configPath = Join-Path $targetRoot "agent-config.json" + [IO.File]::WriteAllText( + $configPath, + ($config | ConvertTo-Json -Depth 20) + [Environment]::NewLine + ) + $token = "transition-token-123456789012345678901234567890" + $enrollment = [ordered]@{ + workflow = "existing_agent_credential_transition" + hub_url = "https://hub.example.test" + token = $token + } + [IO.File]::WriteAllText( + (Join-Path $packageRoot "enrollment.json"), + ($enrollment | ConvertTo-Json) + [Environment]::NewLine + ) + + $script:Sim = @{ + Root = $testRoot + Target = $targetRoot + Package = $packageRoot + Config = $configPath + Recovery = Join-Path $targetRoot "agent-credential-transition.recovery.json" + Pending = Join-Path $targetRoot "agent-config.json.pending" + Prior = Join-Path $targetRoot "agent-config.pre-credential-transition.json" + Enrollment = Join-Path $packageRoot "enrollment.json" + Token = $token + Secure = @{} + Events = [System.Collections.Generic.List[string]]::new() + Redemptions = [System.Collections.Generic.List[object]]::new() + TaskRunning = $OriginallyRunning + TaskVerificationFailureAfterCommit = $false + ResponseMode = "success" + SwapMode = "success" + FailAclLeaf = "" + FailTaskRestore = $false + InstallerFailure = $false + TaskDefinitionInvalid = $false + TaskStateOverride = $null + CleanupSafe = $false + SwapCalls = 0 + DirectoryTrusted = $true + DirectoryTrustedAtDiscovery = $true + TrustEpochBlocked = $false + TrustResetAuthorized = $false + TrustResetRuntimePublished = $false + TrustResetOriginalTaskRunning = $null + BootChanged = $false + TrustBlocker = Join-Path $testRoot ` + "agent.minerops-filesystem-trust.blocked-simulated.state" + DirectoryReparse = $false + AttemptSwap = $false + } + $script:Sim.Secure[(Get-SimPathKey -Path $configPath)] = $false + Set-TransitionSimulationMocks + return $script:Sim +} + +function Remove-TransitionSimulation { + if ($null -ne $script:Sim -and (Test-Path -LiteralPath $script:Sim.Root)) { + Remove-Item -LiteralPath $script:Sim.Root -Recurse -Force + } + $script:Sim = $null +} + +function Set-TransitionSimulationMocks { + function script:Initialize-MinerOpsCredentialDirectory { + param( + [Parameter(Mandatory = $true)][string]$Path, + [scriptblock]$CheckpointAction, + [switch]$ApproveFilesystemTrustResetAfterReboot + ) + if ($script:Sim.DirectoryReparse) { + throw "Agent credential directory path contains a reparse point." + } + $wasTrusted = $script:Sim.DirectoryTrusted + $script:Sim.DirectoryTrustedAtDiscovery = $wasTrusted + if (-not $wasTrusted) { + if (-not $script:Sim.TrustEpochBlocked) { + [IO.File]::WriteAllText($script:Sim.TrustBlocker, "blocked") + $script:Sim.TrustEpochBlocked = $true + Invoke-MinerOpsTransitionCheckpoint ` + -Name "directory-repair-blocker-written" ` + -Action $CheckpointAction + } + $script:Sim.Events.Add("repair-directory") + $script:Sim.DirectoryTrusted = $true + Invoke-MinerOpsTransitionCheckpoint ` + -Name "directory-trust-established" ` + -Action $CheckpointAction + } + $newlyBlocked = -not $wasTrusted + $approved = $false + if ($script:Sim.TrustEpochBlocked -and -not $newlyBlocked) { + if (-not $ApproveFilesystemTrustResetAfterReboot) { + throw "filesystem trust epoch is blocked" + } + if (-not $script:Sim.BootChanged) { + throw "without a verified reboot" + } + $approved = $true + } + return [pscustomobject]@{ + Path = [IO.Path]::GetFullPath($Path) + WasTrustedAtDiscovery = $wasTrusted + WasRepaired = -not $wasTrusted + TrustEpochBlocked = $script:Sim.TrustEpochBlocked + TrustEpochNewlyBlocked = $newlyBlocked + TrustResetApproved = $approved + TrustResetAuthorized = $script:Sim.TrustResetAuthorized + TrustResetRuntimePublished = ` + $script:Sim.TrustResetRuntimePublished + TrustResetOriginalTaskRunning = ` + $script:Sim.TrustResetOriginalTaskRunning + TrustBlockerPaths = @($script:Sim.TrustBlocker) + } + } + + function script:Assert-MinerOpsCredentialDirectory { + param([Parameter(Mandatory = $true)][string]$Path) + if ($script:Sim.DirectoryReparse) { + throw "Agent credential directory path contains a reparse point." + } + if (-not $script:Sim.DirectoryTrusted) { + throw "Agent credential directory owner or DACL is not trusted." + } + $script:Sim.Events.Add("assert-directory") + if ($script:Sim.AttemptSwap) { + $script:Sim.Events.Add("swap-blocked-by-directory") + } + return [IO.Path]::GetFullPath($Path) + } + + function script:Initialize-MinerOpsQuarantineDirectory { + param([Parameter(Mandatory = $true)][string]$TargetRoot) + $path = Join-Path $TargetRoot ` + "minerops-credential-transition-quarantine" + if (-not (Test-Path -LiteralPath $path)) { + [void](New-Item -ItemType Directory -Path $path) + } + return [IO.Path]::GetFullPath($path) + } + + function script:Protect-MinerOpsQuarantineEvidence { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$QuarantineRoot + ) + $script:Sim.Secure[(Get-SimPathKey -Path $Path)] = $true + } + + function script:Get-MinerOpsCredentialArtifactProvenance { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedParent, + [Parameter(Mandatory = $true)][string]$Role, + [Parameter(Mandatory = $true)][bool]$DirectoryWasTrustedAtDiscovery, + [switch]$CreatedByCurrentTransition + ) + if ($Role -eq "Active") { return "UnknownUntrusted" } + if ($Role -eq "Quarantine") { + return "ProtectedButUntrustedQuarantined" + } + $key = Get-SimPathKey -Path $Path + if ( + $CreatedByCurrentTransition -or + ($DirectoryWasTrustedAtDiscovery -and + $script:Sim.Secure.ContainsKey($key) -and + $script:Sim.Secure[$key]) + ) { + return "ProtectedTransitionCandidate" + } + return "UnknownUntrusted" + } + + function script: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 } + $sourceKey = Get-SimPathKey -Path $Path + $script:Sim.Events.Add("contain-without-read:$([IO.Path]::GetFileName($Path))") + $quarantineRoot = Initialize-MinerOpsQuarantineDirectory ` + -TargetRoot $ExpectedParent + $destination = Join-Path $quarantineRoot ( + "minerops-quarantine-credential-transition-$Role-" + + [guid]::NewGuid().ToString("N") + ".evidence" + ) + [IO.File]::Move($Path, $destination) + Invoke-MinerOpsTransitionCheckpoint ` + -Name "artifact-moved-to-quarantine" ` + -Action $CheckpointAction + $script:Sim.Secure.Remove($sourceKey) + $script:Sim.Secure[(Get-SimPathKey -Path $destination)] = $true + $script:Sim.Events.Add("quarantine:$([IO.Path]::GetFileName($Path))") + return $destination + } + + function script:Read-MinerOpsProtectedCredentialText { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedParent, + [switch]$AllowAclRepair + ) + $key = Get-SimPathKey -Path $Path + if (-not $script:Sim.Secure.ContainsKey($key) -or + -not $script:Sim.Secure[$key]) { + $script:Sim.Events.Add("repair:$([IO.Path]::GetFileName($Path))") + $script:Sim.Secure[$key] = $true + if (-not $AllowAclRepair) { + throw "Credential transition artifact had an insecure owner or DACL." + } + } + $script:Sim.Events.Add("read:$([IO.Path]::GetFileName($Path))") + return [IO.File]::ReadAllText($Path) + } + + function script:Write-MinerOpsProtectedCredentialJson { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedParent, + [Parameter(Mandatory = $true)]$Value + ) + $leaf = [IO.Path]::GetFileName($Path) + $key = Get-SimPathKey -Path $Path + if (Test-Path -LiteralPath $Path) { + throw "CreateNew refused existing artifact." + } + [IO.File]::Create($Path).Dispose() + $script:Sim.Events.Add("create-empty:$leaf") + $script:Sim.Events.Add("protect:$leaf") + if ($script:Sim.FailAclLeaf -eq $leaf) { + Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + throw "simulated ACL establishment failure" + } + $script:Sim.Secure[$key] = $true + $script:Sim.Events.Add("verify-acl:$leaf") + [IO.File]::WriteAllText( + $Path, + ($Value | ConvertTo-Json -Depth 50) + [Environment]::NewLine + ) + $script:Sim.Events.Add("write-secret:$leaf") + } + + function script:Open-MinerOpsProtectedCredentialFile { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedParent, + [switch]$AllowAclRepair + ) + $key = Get-SimPathKey -Path $Path + if (-not $script:Sim.Secure.ContainsKey($key) -or + -not $script:Sim.Secure[$key]) { + $script:Sim.Secure[$key] = $true + if (-not $AllowAclRepair) { + throw "Credential transition artifact had an insecure owner or DACL." + } + } + return [IO.File]::Open( + $Path, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, + [IO.FileShare]::None + ) + } + + function script:Remove-MinerOpsProtectedCredentialArtifact { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedParent + ) + if (-not (Test-Path -LiteralPath $Path)) { return } + $leaf = [IO.Path]::GetFileName($Path) + if ($leaf -eq "agent-credential-transition.recovery.json") { + $active = [IO.File]::ReadAllText($script:Sim.Config) | + ConvertFrom-Json + $script:Sim.CleanupSafe = ( + [string]$active.agent_secret -ne "legacy-shared-secret" -and + $script:Sim.TaskRunning -eq + [bool](ConvertFrom-Json ([IO.File]::ReadAllText($Path))).original_task_running + ) + } + Remove-Item -LiteralPath $Path -Force + $script:Sim.Secure.Remove((Get-SimPathKey -Path $Path)) + $script:Sim.Events.Add("remove:$leaf") + } + + function script:Invoke-MinerOpsRuntimeInstaller { + param( + [Parameter(Mandatory = $true)][string]$Path, + [switch]$ApproveFilesystemTrustResetAfterReboot + ) + if ($ApproveFilesystemTrustResetAfterReboot) { + $script:Sim.Events.Add("trust-reset-runtime-publication") + if ($script:Sim.InstallerFailure) { + throw "simulated runtime publication failure" + } + $script:Sim.TaskRunning = $false + } + $script:Sim.Events.Add("install") + } + + function script:Set-MinerOpsFilesystemTrustBlockerRuntimePublished { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$TargetRoot, + [Parameter(Mandatory = $true)][bool]$OriginalTaskRunning + ) + if (-not (Test-Path -LiteralPath $Path) -or + -not $script:Sim.TrustResetAuthorized) { + throw "simulated blocker disappeared before publication state" + } + $script:Sim.TrustResetRuntimePublished = $true + $script:Sim.TrustResetOriginalTaskRunning = $OriginalTaskRunning + $script:Sim.Events.Add("trust-blocker:runtime-published") + } + + function script:Set-MinerOpsFilesystemTrustBlockerResetAuthorized { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$TargetRoot, + [Parameter(Mandatory = $true)][bool]$OriginalTaskRunning + ) + if (-not (Test-Path -LiteralPath $Path)) { + throw "simulated blocker disappeared before reset authorization" + } + $script:Sim.TrustResetAuthorized = $true + $script:Sim.TrustResetOriginalTaskRunning = $OriginalTaskRunning + $script:Sim.Events.Add("trust-blocker:reset-authorized") + } + + function script:Remove-MinerOpsFilesystemTrustBlockerAfterConvergence { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$TargetRoot + ) + if (-not $script:Sim.TrustResetRuntimePublished) { + throw "simulated blocker removed before runtime publication" + } + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop + $script:Sim.TrustEpochBlocked = $false + $script:Sim.Events.Add("trust-blocker:removed-after-convergence") + } + + function script:Get-MinerOpsVerifiedScheduledTask { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$TargetRoot + ) + if ($script:Sim.TaskVerificationFailureAfterCommit -and + $script:Sim.Redemptions.Count -gt 0) { + throw "simulated strict task verification failure" + } + if ($script:Sim.TaskDefinitionInvalid) { + throw "simulated strict task definition failure" + } + $state = if ($null -ne $script:Sim.TaskStateOverride) { + [string]$script:Sim.TaskStateOverride + } + elseif ($script:Sim.TaskRunning) { "Running" } else { "Ready" } + return [pscustomobject]@{ + Task = [pscustomobject]@{ TaskName = $Name } + State = $state + } + } + + function script:Set-MinerOpsScheduledTaskState { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$TargetRoot, + [Parameter(Mandatory = $true)][bool]$ShouldRun, + [TimeSpan]$Timeout = [TimeSpan]::FromSeconds(30) + ) + if ($script:Sim.FailTaskRestore -and $ShouldRun) { + throw "simulated task restart failure" + } + $script:Sim.TaskRunning = $ShouldRun + $script:Sim.Events.Add("task:$ShouldRun") + } + + function script:Stop-MinerOpsAgentTask { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$TargetRoot + ) + $script:Sim.TaskRunning = $false + $script:Sim.Events.Add("task:stopped-for-transition") + } + + function script:Invoke-MinerOpsCredentialRedemption { + param( + [Parameter(Mandatory = $true)][string]$HubUrl, + [Parameter(Mandatory = $true)][string]$Token, + [Parameter(Mandatory = $true)][string]$SecretHash + ) + $script:Sim.Redemptions.Add([pscustomobject]@{ + Token = $Token + SecretHash = $SecretHash + }) + if ($script:Sim.ResponseMode -eq "lost-once" -and + $script:Sim.Redemptions.Count -eq 1) { + throw "simulated lost HTTP response after commit" + } + return [pscustomobject]@{ + data = [pscustomobject]@{ + agent_id = "00000000-0000-4000-8000-000000000111" + site_id = "00000000-0000-4000-8000-000000000222" + site_name = "Test Site" + agent_zone_id = "00000000-0000-4000-8000-000000000333" + agent_zone_name = "Test Zone" + } + } + } + + function script: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 + ) + $script:Sim.SwapCalls++ + if ($script:Sim.SwapMode -eq "fail-before-once" -and + $script:Sim.SwapCalls -eq 1) { + throw "simulated replacement failure" + } + if ($script:Sim.SwapMode -eq "active-missing-pending-once" -and + $script:Sim.SwapCalls -eq 1) { + Remove-Item -LiteralPath $ConfigPath -Force + $script:Sim.Secure.Remove((Get-SimPathKey -Path $ConfigPath)) + throw "simulated ERROR_UNABLE_TO_MOVE_REPLACEMENT" + } + + if (Test-Path -LiteralPath $ConfigPath) { + [void](Assert-MinerOpsCredentialDirectory -Path $TargetRoot) + $script:Sim.Events.Add("directory-verified-before-active-move") + [IO.File]::Move($ConfigPath, $PriorPath) + $script:Sim.Secure[(Get-SimPathKey -Path $PriorPath)] = ` + $script:Sim.Secure[(Get-SimPathKey -Path $ConfigPath)] + $script:Sim.Secure.Remove((Get-SimPathKey -Path $ConfigPath)) + Invoke-MinerOpsTransitionCheckpoint ` + -Name "active-moved-to-prior" -Action $CheckpointAction + } + if ($script:Sim.SwapMode -eq "active-to-prior-fail-once" -and + $script:Sim.SwapCalls -eq 1) { + throw "simulated ERROR_UNABLE_TO_MOVE_REPLACEMENT_2" + } + + [void](Assert-MinerOpsCredentialDirectory -Path $TargetRoot) + $script:Sim.Events.Add("directory-verified-before-pending-move") + [IO.File]::Move($PendingPath, $ConfigPath) + $script:Sim.Secure[(Get-SimPathKey -Path $ConfigPath)] = $true + $script:Sim.Secure.Remove((Get-SimPathKey -Path $PendingPath)) + $script:Sim.Events.Add("recoverable-install") + Invoke-MinerOpsTransitionCheckpoint ` + -Name "pending-moved-to-active" -Action $CheckpointAction + if ($script:Sim.SwapMode -eq "move-then-fail-once" -and + $script:Sim.SwapCalls -eq 1) { + throw "simulated ambiguous post-replacement failure" + } + } +} + +function Invoke-SimulatedTransition { + param( + [scriptblock]$Checkpoint, + [switch]$ApproveFilesystemTrustResetAfterReboot + ) + + Invoke-MinerOpsAgentCredentialTransition ` + -TargetRoot $script:Sim.Target ` + -PackageRoot $script:Sim.Package ` + -ScheduledTaskName "MinerOpsAgent-Test" ` + -CheckpointAction $Checkpoint ` + -ApproveFilesystemTrustResetAfterReboot:$ApproveFilesystemTrustResetAfterReboot +} + +function Get-SimRecovery { + return [IO.File]::ReadAllText($script:Sim.Recovery) | ConvertFrom-Json +} + +function Set-SimPendingObject { + param([Parameter(Mandatory = $true)]$Value) + [IO.File]::WriteAllText( + $script:Sim.Pending, + ($Value | ConvertTo-Json -Depth 50) + [Environment]::NewLine + ) +} + +# Source invariants complement the behavioral harness. In particular, JSON is +# not even serialized until after the empty file's DACL has been read back. +Assert-SourceContains 'existing_agent_credential_transition' ` + "Transition package workflow is not validated." +Assert-SourceContains '/api/agent/credential-transition' ` + "Dedicated credential transition endpoint is missing." +Assert-SourceContains 'FileMode\]::CreateNew' ` + "Secret artifacts are not created without overwrite." +Assert-SourceContains 'Test-MinerOpsCredentialAcl' ` + "Credential artifact DACL verification is missing." +Assert-SourceContains 'original_task_running' ` + "Original task state is not durable." +Assert-SourceContains 'transition_token_hash' ` + "Recovery does not bind the transition token." +Assert-SourceContains 'agent_secret_hash' ` + "Recovery does not bind the generated credential." +Assert-SourceContains 'Assert-MinerOpsNoReparseDirectoryPath' ` + "Full directory-path reparse validation is missing." +Assert-SourceContains 'Test-MinerOpsAgentDirectoryAcl' ` + "Credential directory DACL verification is missing." +Assert-SourceContains 'TrustedCurrentTransitionState' ` + "Trusted transition provenance is not explicit." +Assert-SourceContains 'TrustedActiveConfig' ` + "Trusted active-config provenance is not explicit." +Assert-SourceContains 'ProtectedButUntrustedQuarantined' ` + "Quarantined provenance is not explicit." +Assert-SourceContains 'UnknownUntrusted' ` + "Unknown artifact provenance is not explicit." +Assert-SourceContains 'minerops-quarantine-credential-transition' ` + "Non-authoritative quarantine naming is missing." +Assert-SourceContains 'minerops-credential-transition\.blocked' ` + "Durable quarantine blocked-state naming is missing." +Assert-SourceContains 'minerops-credential-transition-quarantine' ` + "Protected quarantine directory naming is missing." +Assert-SourceContains 'marker exists before ACL repair' ` + "ACL-repair laundering protection is not documented in source." +Assert-SourceContains 'Invoke-MinerOpsRecoverableConfigInstall' ` + "Recoverable config installation is missing." +Assert-SourceContains 'active-moved-to-prior' ` + "Known active-to-prior intermediate state is missing." +Assert-SourceContains 'pending-moved-to-active' ` + "Known pending-to-active intermediate state is missing." +Assert-SourceContains 'before-recovery-cleanup' ` + "Success cleanup checkpoint is missing." +Assert-SourceContains 'Remove-MinerOpsFilesystemTrustBlockerAfterConvergence' ` + "Parent-owned post-convergence trust-blocker removal is missing." +Assert-SourceContains 'windows-full-boot-event-v1:' ` + "Canonical Windows full-kernel-boot representation is missing." +Assert-True ($source -notmatch 'LastBootUpTime') ` + "Wall-clock boot timestamps must not authorize a trust reset." + +foreach ($functionName in @( + "Assert-MinerOpsNoReparseDirectoryPath", + "Assert-MinerOpsTrustedAncestorNamespace" +)) { + $functionSource = [regex]::Match( + $source, + "(?s)function $functionName \{.*?\n\}" + ).Value + Assert-True (-not [string]::IsNullOrWhiteSpace($functionSource)) ` + "$functionName source was not found." + Assert-True ($functionSource -match '\$current\s*=\s*\$root') ` + "$functionName does not preserve the filesystem root." + Assert-True ($functionSource -match ` + '\$fullPath\.Equals\(\s*\$root') ` + "$functionName does not protect a root-valued full path from trimming." + Assert-True ($functionSource -notmatch '\$root\.TrimEnd\(') ` + "$functionName converts a root-qualified path to drive-relative form." + Assert-True ($functionSource -notmatch ` + 'GetFullPath\(\$Path\)\.TrimEnd\(') ` + "$functionName trims the filesystem root before identifying it." + if ($functionName -eq "Assert-MinerOpsTrustedAncestorNamespace") { + Assert-True ($functionSource -match ` + '\$parent\.Equals\(\s*\$root') ` + "$functionName does not protect a root-valued parent from trimming." + } +} + +$taskRestoreIndex = $source.LastIndexOf('-Name "task-restored"') +$nonRecoveryCleanupIndex = $source.LastIndexOf('-Path $pendingPath') +$blockerRemovalIndex = $source.LastIndexOf( + 'Remove-MinerOpsFilesystemTrustBlockerAfterConvergence' +) +$recoveryCleanupIndex = $source.LastIndexOf('-Path $recoveryPath') +Assert-True ( + $taskRestoreIndex -ge 0 -and + $nonRecoveryCleanupIndex -gt $taskRestoreIndex -and + $blockerRemovalIndex -gt $nonRecoveryCleanupIndex -and + $recoveryCleanupIndex -gt $blockerRemovalIndex +) "Blocker deletion must follow convergence and non-recovery cleanup while recovery remains last." + +function New-TestTrustBlockerRecord { + return [pscustomobject][ordered]@{ + version = 2 + state = "blocked" + reason = "existing-agent-directory-was-untrusted" + target_path = [IO.Path]::GetFullPath($script:VerifierTarget) + detected_boot_id = "windows-full-boot-event-v1:000000000000002a" + reset_boot_id = $null + original_task_running = $null + created_at = "2026-09-19T00:00:00.0000000Z" + } +} + +$script:VerifierTarget = Join-Path ([IO.Path]::GetTempPath()) ` + "minerops-verifier-target" + +Invoke-BehaviorTest "production blocker parser accepts canonical boot epoch" { + $json = (New-TestTrustBlockerRecord) | ConvertTo-Json -Depth 10 + $record = ConvertFrom-MinerOpsJson ` + -Json $json -Description "test filesystem trust blocker" + [void](Assert-MinerOpsFilesystemTrustBlockerRecord ` + -Record $record -TargetRoot $script:VerifierTarget) +} +Invoke-BehaviorTest "malformed stored BootId fails closed" { + $record = New-TestTrustBlockerRecord + $record.detected_boot_id = "not-a-boot-id" + Assert-Throws { Assert-MinerOpsFilesystemTrustBlockerRecord ` + -Record $record -TargetRoot $script:VerifierTarget } "canonical" +} +Invoke-BehaviorTest "missing stored BootId fails closed" { + $record = New-TestTrustBlockerRecord + $record.PSObject.Properties.Remove("detected_boot_id") + Assert-Throws { Assert-MinerOpsFilesystemTrustBlockerRecord ` + -Record $record -TargetRoot $script:VerifierTarget } "invalid" +} +Invoke-BehaviorTest "empty stored BootId fails closed" { + $record = New-TestTrustBlockerRecord + $record.detected_boot_id = "" + Assert-Throws { Assert-MinerOpsFilesystemTrustBlockerRecord ` + -Record $record -TargetRoot $script:VerifierTarget } "canonical" +} +Invoke-BehaviorTest "wrong-type stored BootId fails closed" { + $record = New-TestTrustBlockerRecord + $record.detected_boot_id = 42 + Assert-Throws { Assert-MinerOpsFilesystemTrustBlockerRecord ` + -Record $record -TargetRoot $script:VerifierTarget } "canonical" +} +Invoke-BehaviorTest "additional blocker representation fails closed" { + $record = New-TestTrustBlockerRecord + $record | Add-Member -NotePropertyName boot_timestamp ` + -NotePropertyValue "2026-09-19T00:00:00Z" + Assert-Throws { Assert-MinerOpsFilesystemTrustBlockerRecord ` + -Record $record -TargetRoot $script:VerifierTarget } "invalid" +} +Invoke-BehaviorTest "current boot-epoch query failure fails closed" { + Assert-Throws { Get-MinerOpsBootIdentifier -QueryAction { + throw "simulated registry failure" + } } "could not be verified" +} +Invoke-BehaviorTest "malformed current boot-epoch query fails closed" { + Assert-Throws { Get-MinerOpsBootIdentifier -QueryAction { + [pscustomobject]@{ + RecordId = "42" + EventId = [int]27 + ProviderName = "Microsoft-Windows-Kernel-Boot" + Version = [byte]1 + BootType = [int]0 + } + } } "could not be verified" +} +Invoke-BehaviorTest "duplicated current full-boot identity fails closed" { + Assert-Throws { Get-MinerOpsBootIdentifier -QueryAction { + 1..2 | ForEach-Object { + [pscustomobject]@{ + RecordId = [long]42 + EventId = [int]27 + ProviderName = "Microsoft-Windows-Kernel-Boot" + Version = [byte]1 + BootType = [int]0 + } + } + } } "could not be verified" +} +Invoke-BehaviorTest "wrong-type current BootType fails closed" { + Assert-Throws { Get-MinerOpsBootIdentifier -QueryAction { + [pscustomobject]@{ + RecordId = [long]42 + EventId = [int]27 + ProviderName = "Microsoft-Windows-Kernel-Boot" + Version = [byte]1 + BootType = "0" + } + } } "could not be verified" +} +Invoke-BehaviorTest "canonical full-kernel-boot event query succeeds" { + $bootId = Get-MinerOpsBootIdentifier -QueryAction { + [pscustomobject]@{ + RecordId = [long]42 + EventId = [int]27 + ProviderName = "Microsoft-Windows-Kernel-Boot" + Version = [byte]1 + BootType = [int]0 + } + } + Assert-True ( + $bootId -ceq "windows-full-boot-event-v1:000000000000002a" + ) "Full-kernel-boot event was not canonicalized." +} +Invoke-BehaviorTest "Fast Startup event does not advance full-boot epoch" { + $bootId = Get-MinerOpsBootIdentifier -QueryAction { + [pscustomobject]@{ + RecordId = [long]43 + EventId = [int]27 + ProviderName = "Microsoft-Windows-Kernel-Boot" + Version = [byte]1 + BootType = [int]1 + } + [pscustomobject]@{ + RecordId = [long]42 + EventId = [int]27 + ProviderName = "Microsoft-Windows-Kernel-Boot" + Version = [byte]1 + BootType = [int]0 + } + } + Assert-True ( + $bootId -ceq "windows-full-boot-event-v1:000000000000002a" + ) "Fast Startup advanced the full-kernel-boot epoch." +} +Invoke-BehaviorTest "hibernate event does not advance full-boot epoch" { + $bootId = Get-MinerOpsBootIdentifier -QueryAction { + [pscustomobject]@{ + RecordId = [long]44 + EventId = [int]27 + ProviderName = "Microsoft-Windows-Kernel-Boot" + Version = [byte]1 + BootType = [int]2 + } + [pscustomobject]@{ + RecordId = [long]42 + EventId = [int]27 + ProviderName = "Microsoft-Windows-Kernel-Boot" + Version = [byte]1 + BootType = [int]0 + } + } + Assert-True ( + $bootId -ceq "windows-full-boot-event-v1:000000000000002a" + ) "Hibernate advanced the full-kernel-boot epoch." +} +Invoke-BehaviorTest "same canonical boot epoch denies reset" { + Assert-Throws { Assert-MinerOpsBootEpochChanged ` + -StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" ` + -CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002a" } ` + "without a verified full Restart" +} +Invoke-BehaviorTest "different canonical boot epoch passes epoch check only" { + Assert-MinerOpsBootEpochChanged ` + -StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" ` + -CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b" +} +Invoke-BehaviorTest "older canonical boot epoch denies reset" { + Assert-Throws { Assert-MinerOpsBootEpochChanged ` + -StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" ` + -CurrentBootIdentifier "windows-full-boot-event-v1:0000000000000029" } ` + "older than the" +} +$bootQueryFunction = [regex]::Match( + $source, + '(?s)function Get-MinerOpsBootIdentifier \{.*?\n\}' +).Value +Assert-True ( + $bootQueryFunction -notmatch 'Get-Date|DateTime|LastBootUpTime' -and + $bootQueryFunction -match 'Get-WinEvent' -and + $bootQueryFunction -match 'BootType' +) "Boot epoch comparison must not derive from the wall clock." + +$writeFunction = [regex]::Match( + $source, + '(?s)function Write-MinerOpsProtectedCredentialJson \{.*?\n\}' +).Value +$createIndex = $writeFunction.IndexOf( + "New-MinerOpsProtectedFileCreateNewStream" +) +$verifyIndex = $writeFunction.IndexOf("Test-MinerOpsCredentialAcl") +$jsonIndex = $writeFunction.IndexOf('$json =') +Assert-True ( + $createIndex -ge 0 -and $verifyIndex -gt $createIndex -and + $jsonIndex -gt $verifyIndex -and + $writeFunction -notmatch 'Set-MinerOpsStreamAcl' +) "Secret serialization must follow protected creation and ACL verification." + +$createStreamFunction = [regex]::Match( + $source, + '(?s)function New-MinerOpsProtectedFileCreateNewStream \{.*?\n\}' +).Value +foreach ($requiredCreateContract in @( + '\[IO\.FileStream\]::new', + 'FileMode\]::CreateNew', + 'FileSystemRights\]::WriteData', + 'FileSystemRights\]::ReadPermissions', + 'FileSystemRights\]::Synchronize', + 'FileShare\]::None', + '\$Acl' +)) { + Assert-True ($createStreamFunction -match $requiredCreateContract) ` + "Protected CreateNew stream contract is incomplete." +} +Assert-True ( + $createStreamFunction -notmatch 'FullControl|SetAccessControl|Set-Acl' +) "Protected creation must use minimum rights and creation-time FileSecurity." + +$openStreamFunction = [regex]::Match( + $source, + '(?s)function Open-MinerOpsFileStreamWithRights \{.*?\n\}' +).Value +Assert-True ( + $openStreamFunction -match 'FileMode\]::Open' -and + $openStreamFunction -match 'FileShare\]::None' -and + $openStreamFunction -match '\$Rights' +) "Existing protected artifacts must use an exclusive explicit-rights handle." + +$repairOpenFunction = [regex]::Match( + $source, + '(?s)function Open-MinerOpsProtectedCredentialFile \{.*?\n\}' +).Value +foreach ($requiredRepairRight in @( + 'ReadData', + 'ReadPermissions', + 'ChangePermissions', + 'TakeOwnership', + 'Synchronize' +)) { + Assert-True ($repairOpenFunction -match $requiredRepairRight) ` + "Existing-artifact repair is missing $requiredRepairRight." +} +Assert-True ( + $repairOpenFunction -match 'Set-MinerOpsStreamAcl' -and + $repairOpenFunction -match 'will not be trusted' -and + $repairOpenFunction -notmatch 'Set-Acl' +) "Existing-artifact repair must remain handle-bound and fail closed." + +$trustBlockerCreateFunction = [regex]::Match( + $source, + '(?s)function New-MinerOpsFilesystemTrustBlocker \{.*?\n\}' +).Value +$blockerCreateIndex = $trustBlockerCreateFunction.IndexOf( + 'New-MinerOpsProtectedFileCreateNewStream' +) +$blockerVerifyIndex = $trustBlockerCreateFunction.IndexOf( + 'Test-MinerOpsCredentialAcl' +) +$blockerContentIndex = $trustBlockerCreateFunction.IndexOf('$record =') +Assert-True ( + $blockerCreateIndex -ge 0 -and + $blockerVerifyIndex -gt $blockerCreateIndex -and + $blockerContentIndex -gt $blockerVerifyIndex +) "Filesystem trust blockers must be protected and verified before content." + +Assert-True ($source -notmatch '\[IO\.File\]::Open') ` + "Credential transition stream security must use explicit FileSystemRights." + +$quarantineFunction = [regex]::Match( + $source, + '(?s)function Move-MinerOpsArtifactToQuarantine \{.*?\n\}' +).Value +$quarantineMoveIndex = $quarantineFunction.IndexOf('[IO.File]::Move') +$quarantineAclIndex = $quarantineFunction.IndexOf('Set-Acl') +Assert-True ( + $quarantineMoveIndex -ge 0 -and + $quarantineAclIndex -gt $quarantineMoveIndex -and + $quarantineFunction -notmatch 'Read(AllText|-MinerOpsProtectedCredentialText)' +) "Untrusted evidence must leave authority before ACL mutation and must never be read." + +foreach ($sensitiveVariable in @( + 'enrollmentToken', 'candidateSecret', 'transition_token', 'agent_secret' +)) { + Assert-True ( + $source -notmatch ( + '(?im)Write-(Host|Output|Verbose|Information).*?\$' + + $sensitiveVariable + ) + ) "$sensitiveVariable must not be written to output." +} +foreach ($productionValue in @('plexus-windows-agent', 'Plexus Mining Site')) { + Assert-True ( + $source -notmatch [regex]::Escape($productionValue) + ) "Production-specific value found in generic transition source." +} + +Assert-True ($source -notmatch 'Get-ScheduledTask[^\r\n]*SilentlyContinue') ` + "Scheduled Task queries must use terminating error behavior." +Assert-SourceContains 'function Get-MinerOpsVerifiedScheduledTask' ` + "The strict Scheduled Task verifier is missing." +Assert-SourceContains 'Settings\.Enabled' ` + "The strict verifier does not require an enabled task." +Assert-SourceContains 'Principal\.UserId' ` + "The strict verifier does not validate the task principal." +Assert-SourceContains 'Actions' ` + "The strict verifier does not validate the task action." + +# Exercise the real strict verifier with deterministic Scheduled Task provider +# results before the transition simulation replaces it with a filesystem-free +# mock. These cases prove that only a valid expected task in exactly Running or +# Ready can satisfy durable intent. +$script:VerifierTasks = @() +$script:VerifierQueryFailure = "" +function script:Get-ScheduledTask { + param($ErrorAction) + if ($script:VerifierQueryFailure -eq "provider") { + throw "provider failure" + } + if ($script:VerifierQueryFailure -eq "access") { + throw (New-Object UnauthorizedAccessException("access denied")) + } + return $script:VerifierTasks +} +function script:New-VerifierTask { + param( + [string]$State = "Ready", + [string]$Execute = "", + [string]$Arguments = "", + [string]$UserId = "SYSTEM", + [bool]$Enabled = $true, + [switch]$Malformed, + [string]$Name = "MinerOpsAgent-Test" + ) + if (-not $Execute) { + $Execute = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" + } + if (-not $Arguments) { + $Arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f + (Join-Path $script:VerifierTarget "MinerOpsAgentService.ps1") + } + $task = [pscustomobject]@{ + TaskName = $Name + TaskPath = "\" + State = $State + Settings = [pscustomobject]@{ Enabled = $Enabled } + Actions = @([pscustomobject]@{ + Execute = $Execute + Arguments = $Arguments + }) + Principal = [pscustomobject]@{ + UserId = $UserId + LogonType = "ServiceAccount" + RunLevel = "Highest" + } + } + if ($Malformed) { $task.Actions = @() } + return $task +} +function script:Set-VerifierTask { + param([Parameter(Mandatory)]$Task) + $script:VerifierQueryFailure = "" + $script:VerifierTasks = @($Task) +} +function script:Start-ScheduledTask { + param([string]$TaskName, $ErrorAction) + $script:VerifierTasks[0].State = "Running" +} +function script:Stop-ScheduledTask { + param([string]$TaskName, $ErrorAction) + $script:VerifierTasks[0].State = "Ready" +} +function script:Start-Sleep { param([int]$Milliseconds) } + +$strictName = "MinerOpsAgent-Test" + +Invoke-BehaviorTest "running intent accepts exactly Running" { + Set-VerifierTask (New-VerifierTask -State "Running") + [void](Assert-MinerOpsScheduledTaskIntent ` + -Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $true) +} +Invoke-BehaviorTest "running intent rejects Ready" { + Set-VerifierTask (New-VerifierTask -State "Ready") + Assert-Throws { Assert-MinerOpsScheduledTaskIntent ` + -Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $true } +} +Invoke-BehaviorTest "running intent rejects missing task" { + $script:VerifierTasks = @() + Assert-Throws { Assert-MinerOpsScheduledTaskIntent ` + -Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $true } "missing" +} +Invoke-BehaviorTest "running intent rejects query exception" { + $script:VerifierQueryFailure = "provider" + Assert-Throws { Assert-MinerOpsScheduledTaskIntent ` + -Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $true } "query failed" + $script:VerifierQueryFailure = "" +} +Invoke-BehaviorTest "stopped intent accepts exactly Ready" { + Set-VerifierTask (New-VerifierTask -State "Ready") + [void](Assert-MinerOpsScheduledTaskIntent ` + -Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false) +} +Invoke-BehaviorTest "stopped intent stops Running then verifies Ready" { + Set-VerifierTask (New-VerifierTask -State "Running") + [void](Set-MinerOpsScheduledTaskState ` + -Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false) + [void](Assert-MinerOpsScheduledTaskIntent ` + -Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false) +} +Invoke-BehaviorTest "stopped intent rejects missing task" { + $script:VerifierTasks = @() + Assert-Throws { Assert-MinerOpsScheduledTaskIntent ` + -Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false } "missing" +} +Invoke-BehaviorTest "stopped intent rejects provider exception" { + $script:VerifierQueryFailure = "provider" + Assert-Throws { Assert-MinerOpsScheduledTaskIntent ` + -Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false } "query failed" + $script:VerifierQueryFailure = "" +} +Invoke-BehaviorTest "stopped intent rejects access denied" { + $script:VerifierQueryFailure = "access" + Assert-Throws { Assert-MinerOpsScheduledTaskIntent ` + -Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false } "query failed" + $script:VerifierQueryFailure = "" +} +Invoke-BehaviorTest "stopped intent rejects empty state" { + Set-VerifierTask (New-VerifierTask -State "") + Assert-Throws { Assert-MinerOpsScheduledTaskIntent ` + -Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false } "malformed" +} +Invoke-BehaviorTest "stopped intent rejects Disabled" { + Set-VerifierTask (New-VerifierTask -State "Disabled" -Enabled:$false) + Assert-Throws { Assert-MinerOpsScheduledTaskIntent ` + -Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false } "disabled" +} +foreach ($invalidState in @("Unknown", "Queued")) { + Invoke-BehaviorTest "stopped intent rejects $invalidState" { + Set-VerifierTask (New-VerifierTask -State $invalidState) + Assert-Throws { Assert-MinerOpsScheduledTaskIntent ` + -Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false } + } +} +Invoke-BehaviorTest "strict verifier rejects malformed task object" { + Set-VerifierTask (New-VerifierTask -Malformed) + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } "exactly one" +} +Invoke-BehaviorTest "strict verifier rejects multiple results" { + $script:VerifierTasks = @( + (New-VerifierTask), + (New-VerifierTask) + ) + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } "multiple" +} +Invoke-BehaviorTest "strict verifier rejects wrong executable" { + Set-VerifierTask (New-VerifierTask -Execute "C:\Windows\System32\cmd.exe") + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } "executable" +} +Invoke-BehaviorTest "strict verifier rejects wrong script arguments" { + Set-VerifierTask (New-VerifierTask -Arguments '-NoProfile -File "C:\wrong.ps1"') + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } "arguments" +} +Invoke-BehaviorTest "strict verifier rejects wrong principal" { + Set-VerifierTask (New-VerifierTask -UserId "BUILTIN\Users") + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } "principal" +} +Invoke-BehaviorTest "strict verifier rejects missing TaskPath" { + $task = New-VerifierTask + $task.PSObject.Properties.Remove("TaskPath") + Set-VerifierTask $task + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } "task path" +} +Invoke-BehaviorTest "strict verifier rejects null TaskPath" { + $task = New-VerifierTask + $task.TaskPath = $null + Set-VerifierTask $task + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } "task path" +} +Invoke-BehaviorTest "strict verifier rejects empty TaskPath" { + $task = New-VerifierTask + $task.TaskPath = "" + Set-VerifierTask $task + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } "task path" +} +Invoke-BehaviorTest "strict verifier rejects wrong-type TaskPath" { + $task = New-VerifierTask + $task.TaskPath = 1 + Set-VerifierTask $task + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } "task path" +} +Invoke-BehaviorTest "strict verifier rejects non-root TaskPath" { + $task = New-VerifierTask + $task.TaskPath = "\MinerOps\" + Set-VerifierTask $task + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } "task path" +} +Invoke-BehaviorTest "strict verifier rejects missing Settings" { + $task = New-VerifierTask + $task.PSObject.Properties.Remove("Settings") + Set-VerifierTask $task + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } "enabled" +} +Invoke-BehaviorTest "strict verifier rejects missing Enabled" { + $task = New-VerifierTask + $task.Settings.PSObject.Properties.Remove("Enabled") + Set-VerifierTask $task + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } "enabled" +} +Invoke-BehaviorTest "strict verifier rejects Boolean false Enabled" { + Set-VerifierTask (New-VerifierTask -Enabled:$false) + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } "enabled" +} +Invoke-BehaviorTest "strict verifier rejects string False Enabled" { + $task = New-VerifierTask + $task.Settings.Enabled = "False" + Set-VerifierTask $task + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } "enabled" +} +Invoke-BehaviorTest "strict verifier rejects numeric Enabled" { + $task = New-VerifierTask + $task.Settings.Enabled = 1 + Set-VerifierTask $task + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } "enabled" +} +Invoke-BehaviorTest "strict verifier accepts actual Boolean true Enabled" { + Set-VerifierTask (New-VerifierTask -Enabled:$true) + [void](Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget) +} +foreach ($malformation in @( + "numeric-state", "string-actions", "numeric-execute", "array-arguments", + "string-principal", "numeric-user", "numeric-logon", "numeric-runlevel" +)) { + Invoke-BehaviorTest "strict verifier rejects $malformation property" { + $task = New-VerifierTask + switch ($malformation) { + "numeric-state" { $task.State = 3 } + "string-actions" { $task.Actions = "powershell.exe" } + "numeric-execute" { $task.Actions[0].Execute = 1 } + "array-arguments" { $task.Actions[0].Arguments = @("arg1", "arg2") } + "string-principal" { $task.Principal = "SYSTEM" } + "numeric-user" { $task.Principal.UserId = 18 } + "numeric-logon" { $task.Principal.LogonType = 5 } + "numeric-runlevel" { $task.Principal.RunLevel = 1 } + } + Set-VerifierTask $task + Assert-Throws { Get-MinerOpsVerifiedScheduledTask ` + -Name $strictName -TargetRoot $script:VerifierTarget } + } +} + +try { + Invoke-BehaviorTest "normal successful transition" { + $sim = New-TransitionSimulation -OriginallyRunning $true + Invoke-SimulatedTransition | Out-Null + $active = [IO.File]::ReadAllText($sim.Config) | ConvertFrom-Json + Assert-True ($active.agent_secret -ne "legacy-shared-secret") ` + "Permanent secret was not installed." + Assert-True $sim.TaskRunning "Originally running task did not end running." + Assert-True (-not (Test-Path $sim.Recovery)) "Recovery was not cleaned." + Assert-True $sim.CleanupSafe "Cleanup occurred before success criteria." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "protected-before-secret writes" { + $sim = New-TransitionSimulation -OriginallyRunning $false + Invoke-SimulatedTransition | Out-Null + foreach ($leaf in @( + "agent-credential-transition.recovery.json", + "agent-config.json.pending" + )) { + $create = $sim.Events.IndexOf("create-empty:$leaf") + $protect = $sim.Events.IndexOf("protect:$leaf") + $verify = $sim.Events.IndexOf("verify-acl:$leaf") + $write = $sim.Events.IndexOf("write-secret:$leaf") + Assert-True ( + $create -ge 0 -and $protect -gt $create -and + $verify -gt $protect -and $write -gt $verify + ) "ACL ordering failed for $leaf." + } + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "ACL failure blocks redemption without leaked content" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $sim.FailAclLeaf = "agent-credential-transition.recovery.json" + Assert-Throws { Invoke-SimulatedTransition } "ACL establishment failure" + Assert-True ($sim.Redemptions.Count -eq 0) ` + "Redemption proceeded after ACL failure." + Assert-True (-not (Test-Path $sim.Recovery)) ` + "Insecure recovery artifact remained after handled ACL failure." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "insecure recovery is quarantined and blocked on two runs" { + $sim = New-TransitionSimulation -OriginallyRunning $true + Assert-Throws { + Invoke-SimulatedTransition -Checkpoint { + param($name) + if ($name -eq "recovery-written") { throw "stop" } + } + } "stop" + $malicious = Get-SimRecovery + $malicious.agent_secret = "attacker-provided-secret" + $malicious.agent_secret_hash = Get-MinerOpsSha256 ` + -Value $malicious.agent_secret + $malicious.original_task_running = $false + [IO.File]::WriteAllText( + $sim.Recovery, + ($malicious | ConvertTo-Json -Depth 50) + [Environment]::NewLine + ) + $sim.Events.Clear() + $sim.Secure[(Get-SimPathKey $sim.Recovery)] = $false + Assert-Throws { Invoke-SimulatedTransition } "evidence is quarantined" + Assert-True ( + $sim.Events -contains "quarantine:agent-credential-transition.recovery.json" + ) "Insecure recovery was not moved out of authority." + Assert-True ( + $sim.Events -notcontains "read:agent-credential-transition.recovery.json" + ) "Insecure recovery contents were read." + Assert-True ($sim.Redemptions.Count -eq 0) ` + "Recovery credential was used after an insecure read." + Assert-Throws { Invoke-SimulatedTransition } "evidence is quarantined" + Assert-True ($sim.Redemptions.Count -eq 0) ` + "Second invocation used quarantined recovery authority." + Assert-True $sim.TaskRunning ` + "Attacker-provided task intent changed the Scheduled Task." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "blocked recovery cannot be laundered by later ACL repair" { + $sim = New-TransitionSimulation -OriginallyRunning $true + Assert-Throws { + Invoke-SimulatedTransition -Checkpoint { + param($name) + if ($name -eq "recovery-written") { throw "stop" } + } + } "stop" + $malicious = Get-SimRecovery + $malicious.agent_secret = "attacker-laundering-secret" + $malicious.agent_secret_hash = Get-MinerOpsSha256 ` + -Value $malicious.agent_secret + $malicious.original_task_running = $false + [IO.File]::WriteAllText( + $sim.Recovery, + ($malicious | ConvertTo-Json -Depth 50) + [Environment]::NewLine + ) + $sim.Secure[(Get-SimPathKey $sim.Recovery)] = $false + Assert-Throws { + Invoke-SimulatedTransition -Checkpoint { + param($name) + if ($name -eq "quarantine-blocker-written") { + throw "termination after quarantine blocker" + } + } + } "termination after quarantine blocker" + Assert-True (Test-Path -LiteralPath $sim.Recovery) ` + "Injected interruption did not leave the authority pathname." + Assert-True ( + @(Get-ChildItem -LiteralPath $sim.Target ` + -Filter "minerops-credential-transition.blocked*.state").Count -gt 0 + ) "Durable blocker was not present before containment." + + # Model an external/legacy ACL repair after interruption. ACL + # correctness must not override the already-durable blocked state. + $sim.Secure[(Get-SimPathKey $sim.Recovery)] = $true + + Assert-Throws { Invoke-SimulatedTransition } "evidence is quarantined" + Assert-True (-not (Test-Path -LiteralPath $sim.Recovery)) ` + "Second invocation retained repaired recovery authority." + Assert-True ($sim.Redemptions.Count -eq 0) ` + "Repaired malicious recovery reached redemption on a later run." + Assert-True $sim.TaskRunning ` + "Repaired malicious recovery changed Scheduled Task intent." + Remove-TransitionSimulation + } + + foreach ($originallyRunning in @($true, $false)) { + $label = if ($originallyRunning) { "running" } else { "stopped" } + Invoke-BehaviorTest "lost response retry restores originally $label task" { + $sim = New-TransitionSimulation -OriginallyRunning $originallyRunning + $sim.ResponseMode = "lost-once" + Assert-Throws { Invoke-SimulatedTransition } "lost HTTP response" + Assert-True (-not $sim.TaskRunning) ` + "Task should remain stopped after ambiguous redemption." + $recoverySecretHash = (Get-SimRecovery).agent_secret_hash + Invoke-SimulatedTransition | Out-Null + Assert-True ($sim.TaskRunning -eq $originallyRunning) ` + "Original task intent was not restored." + Assert-True ($sim.Redemptions.Count -eq 2) ` + "Retry did not redeem twice." + Assert-True ( + $sim.Redemptions[0].SecretHash -eq $recoverySecretHash -and + $sim.Redemptions[1].SecretHash -eq $recoverySecretHash + ) "Retry changed the committed credential." + Remove-TransitionSimulation + } + } + + Invoke-BehaviorTest "crash after server commit before pending converges" { + $sim = New-TransitionSimulation -OriginallyRunning $true + Assert-Throws { + Invoke-SimulatedTransition -Checkpoint { + param($name) + if ($name -eq "server-committed") { throw "crash-after-commit" } + } + } "crash-after-commit" + Assert-True (-not (Test-Path $sim.Pending)) ` + "Pending unexpectedly existed at injected checkpoint." + Invoke-SimulatedTransition | Out-Null + Assert-True $sim.TaskRunning "Retry did not restore task." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "valid protected pending resumes on rerun" { + $sim = New-TransitionSimulation -OriginallyRunning $true + Assert-Throws { + Invoke-SimulatedTransition -Checkpoint { + param($name) + if ($name -eq "pending-written") { throw "crash-after-pending" } + } + } "crash-after-pending" + $writesBefore = @($sim.Events | Where-Object { + $_ -eq "write-secret:agent-config.json.pending" + }).Count + Invoke-SimulatedTransition | Out-Null + $writesAfter = @($sim.Events | Where-Object { + $_ -eq "write-secret:agent-config.json.pending" + }).Count + Assert-True ($writesAfter -eq $writesBefore) ` + "Valid pending config was overwritten instead of resumed." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "failure immediately before replacement converges" { + $sim = New-TransitionSimulation -OriginallyRunning $true + Assert-Throws { + Invoke-SimulatedTransition -Checkpoint { + param($name) + if ($name -eq "before-replace") { throw "before-replace" } + } + } "before-replace" + Assert-True (Test-Path $sim.Pending) "Pending config was lost." + Invoke-SimulatedTransition | Out-Null + Assert-True $sim.TaskRunning "Retry did not finish replacement." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "pre-rename replacement failure converges" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $sim.SwapMode = "fail-before-once" + Assert-Throws { Invoke-SimulatedTransition } "replacement failure" + Assert-True (Test-Path $sim.Pending) ` + "Pending config was lost on replacement failure." + Invoke-SimulatedTransition | Out-Null + Assert-True $sim.TaskRunning "Replacement retry did not restore task." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "post-replacement ambiguity converges" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $sim.SwapMode = "move-then-fail-once" + Assert-Throws { Invoke-SimulatedTransition } "post-replacement" + Assert-True (-not (Test-Path $sim.Pending)) ` + "Recoverable installation should consume pending config." + Assert-True (Test-Path $sim.Prior) ` + "Known prior remnant was not retained." + Invoke-SimulatedTransition | Out-Null + Assert-True $sim.TaskRunning ` + "Already-replaced config was not recovered." + Remove-TransitionSimulation + } + + foreach ($originallyRunning in @($true, $false)) { + $label = if ($originallyRunning) { "running" } else { "stopped" } + Invoke-BehaviorTest "active-missing pending recovery restores originally $label task" { + $sim = New-TransitionSimulation -OriginallyRunning $originallyRunning + $sim.SwapMode = "active-missing-pending-once" + Assert-Throws { Invoke-SimulatedTransition } ` + "ERROR_UNABLE_TO_MOVE_REPLACEMENT" + Assert-True (-not (Test-Path $sim.Config)) ` + "Active config unexpectedly survived the injected partial state." + Assert-True (Test-Path $sim.Pending) ` + "Authenticated pending config was lost." + $recovery = Get-SimRecovery + $first = $sim.Redemptions[0] + Invoke-SimulatedTransition | Out-Null + Assert-True ($sim.Redemptions.Count -eq 2) ` + "Active-missing recovery did not retry idempotent redemption." + Assert-True ( + $sim.Redemptions[1].Token -eq $first.Token -and + $sim.Redemptions[1].SecretHash -eq $first.SecretHash -and + $sim.Redemptions[1].Token -eq $recovery.transition_token -and + $sim.Redemptions[1].SecretHash -eq $recovery.agent_secret_hash + ) "Active-missing recovery changed token or permanent credential." + $active = [IO.File]::ReadAllText($sim.Config) | ConvertFrom-Json + Assert-True ( + [string]$active.agent_id -eq + "00000000-0000-4000-8000-000000000111" -and + [string]$active.site_id -eq + "00000000-0000-4000-8000-000000000222" -and + [string]$active.agent_zone_id -eq + "00000000-0000-4000-8000-000000000333" + ) "Active-missing recovery changed Agent routing." + Assert-True ($sim.TaskRunning -eq $originallyRunning) ` + "Active-missing recovery lost original task intent." + Remove-TransitionSimulation + } + } + + Invoke-BehaviorTest "known active-missing pending prior intermediate converges" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $sim.SwapMode = "active-to-prior-fail-once" + Assert-Throws { Invoke-SimulatedTransition } ` + "ERROR_UNABLE_TO_MOVE_REPLACEMENT_2" + Assert-True (-not (Test-Path $sim.Config)) ` + "Active config should be absent at the known rename midpoint." + Assert-True (Test-Path $sim.Pending) ` + "Pending config should remain at the known rename midpoint." + Assert-True (Test-Path $sim.Prior) ` + "Authenticated prior remnant should represent the moved active file." + Invoke-SimulatedTransition | Out-Null + Assert-True $sim.TaskRunning ` + "Known replacement midpoint did not converge." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "active-missing unknown pending fails closed" { + $sim = New-TransitionSimulation -OriginallyRunning $true + Remove-Item -LiteralPath $sim.Config -Force + $sim.Secure.Remove((Get-SimPathKey -Path $sim.Config)) + [IO.File]::WriteAllText( + $sim.Pending, + '{"agent_secret":"unknown-pending-secret"}' + ) + $sim.Secure[(Get-SimPathKey -Path $sim.Pending)] = $false + $captured = & { + try { Invoke-SimulatedTransition } catch { $_.Exception.Message } + } *>&1 | Out-String + Assert-True ($sim.Redemptions.Count -eq 0) ` + "Unknown active-missing pending state reached redemption." + Assert-True (-not $captured.Contains("unknown-pending-secret")) ` + "Unknown active-missing pending secret reached output." + Assert-Throws { Invoke-SimulatedTransition } "evidence is quarantined" + Remove-TransitionSimulation + } + + foreach ($activeMissingMutation in @("malformed", "agent", "secret")) { + Invoke-BehaviorTest "active-missing $activeMissingMutation pending fails closed" { + $sim = New-TransitionSimulation -OriginallyRunning $true + Assert-Throws { + Invoke-SimulatedTransition -Checkpoint { + param($name) + if ($name -eq "pending-written") { throw "stop" } + } + } "stop" + Remove-Item -LiteralPath $sim.Config -Force + $sim.Secure.Remove((Get-SimPathKey -Path $sim.Config)) + if ($activeMissingMutation -eq "malformed") { + [IO.File]::WriteAllText($sim.Pending, '{"agent_secret":') + } + else { + $pending = [IO.File]::ReadAllText($sim.Pending) | + ConvertFrom-Json + if ($activeMissingMutation -eq "agent") { + $pending.agent_id = "00000000-0000-4000-8000-000000009999" + } + else { + $pending.agent_secret = "mismatched-active-missing-secret" + } + Set-SimPendingObject -Value $pending + } + $calls = $sim.Redemptions.Count + Assert-Throws { Invoke-SimulatedTransition } + Assert-True ($sim.Redemptions.Count -eq $calls) ` + "Invalid active-missing pending state reached redemption." + Remove-TransitionSimulation + } + } + + Invoke-BehaviorTest "prior remnant alone cannot restore active authority" { + $sim = New-TransitionSimulation -OriginallyRunning $true + Assert-Throws { + Invoke-SimulatedTransition -Checkpoint { + param($name) + if ($name -eq "active-moved-to-prior") { + throw "stop-at-rename-midpoint" + } + } + } "stop-at-rename-midpoint" + Remove-Item -LiteralPath $sim.Pending -Force + $sim.Secure.Remove((Get-SimPathKey -Path $sim.Pending)) + $calls = $sim.Redemptions.Count + Assert-Throws { Invoke-SimulatedTransition } ` + "no authenticated pending config" + Assert-True (-not (Test-Path -LiteralPath $sim.Config)) ` + "Prior remnant was promoted into active authority." + Assert-True ($sim.Redemptions.Count -eq $calls) ` + "Prior-only state reached redemption." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "directory junction fails before credential operations" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $sim.DirectoryReparse = $true + Assert-Throws { Invoke-SimulatedTransition } "reparse point" + Assert-True ($sim.Redemptions.Count -eq 0) ` + "Reparse directory reached redemption." + Assert-True (-not (Test-Path $sim.Recovery)) ` + "Reparse directory received new secret state." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "directory repair quarantines authority and blocks three invocations" { + $sim = New-TransitionSimulation -OriginallyRunning $true + Assert-Throws { + Invoke-SimulatedTransition -Checkpoint { + param($name) + if ($name -eq "recovery-written") { throw "stop" } + } + } "stop" + $sim.DirectoryTrusted = $false + Assert-Throws { Invoke-SimulatedTransition } "existing Agent directory was untrusted" + Assert-True ($sim.Events -contains "repair-directory") ` + "Permissive target directory was not repaired." + Assert-True ( + $sim.Events -contains "quarantine:agent-credential-transition.recovery.json" + ) "Pre-repair recovery was promoted instead of quarantined." + Assert-True ($sim.Redemptions.Count -eq 0) ` + "Directory repair promoted pre-existing recovery authority." + Assert-Throws { Invoke-SimulatedTransition } "filesystem trust epoch is blocked" + Assert-True ($sim.Redemptions.Count -eq 0) ` + "Later rerun promoted a pre-repair artifact." + Assert-Throws { Invoke-SimulatedTransition } "filesystem trust epoch is blocked" + Assert-True ($sim.Redemptions.Count -eq 0) ` + "Third invocation escaped the durable trust-epoch blocker." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "clean directory repair contains but never continues automatically" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $sim.DirectoryTrusted = $false + Assert-Throws { Invoke-SimulatedTransition } ` + "existing Agent directory was untrusted" + $repairIndex = $sim.Events.IndexOf("repair-directory") + $recoveryWriteIndex = $sim.Events.IndexOf( + "write-secret:agent-credential-transition.recovery.json" + ) + Assert-True ($repairIndex -ge 0 -and $recoveryWriteIndex -lt 0) ` + "Same-epoch repair created transition credential state." + Assert-True ($sim.Redemptions.Count -eq 0) ` + "Same-epoch repair redeemed a transition token." + Assert-Throws { Invoke-SimulatedTransition } "filesystem trust epoch is blocked" + Assert-Throws { Invoke-SimulatedTransition } "filesystem trust epoch is blocked" + Assert-True (Test-Path -LiteralPath $sim.TrustBlocker) ` + "Ordinary reruns removed the external trust blocker." + Remove-TransitionSimulation + } + + foreach ($lateLeaf in @( + "agent-credential-transition.recovery.json", + "agent-config.json.pending" + )) { + Invoke-BehaviorTest "post-scan $lateLeaf stays non-authoritative" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $sim.DirectoryTrusted = $false + Assert-Throws { + Invoke-SimulatedTransition -Checkpoint { + param($name) + if ($name -eq "directory-containment-scan-complete") { + $latePath = Join-Path $sim.Target $lateLeaf + [IO.File]::WriteAllText( + $latePath, + '{"original_task_running":false,"agent_secret":"late"}' + ) + $sim.Secure[(Get-SimPathKey $latePath)] = $true + } + } + } "existing Agent directory was untrusted" + Assert-True ($sim.Redemptions.Count -eq 0) ` + "Post-scan artifact reached redemption in the repair epoch." + Assert-Throws { Invoke-SimulatedTransition } ` + "filesystem trust epoch is blocked" + Assert-Throws { Invoke-SimulatedTransition } ` + "filesystem trust epoch is blocked" + Assert-True ($sim.Redemptions.Count -eq 0) ` + "Correct-ACL late artifact became authority on an ordinary rerun." + Assert-True $sim.TaskRunning ` + "Late artifact changed durable Scheduled Task intent." + $active = [IO.File]::ReadAllText($sim.Config) | ConvertFrom-Json + Assert-True ( + [string]$active.agent_secret -eq "legacy-shared-secret" + ) "Late pending state was activated while the blocker existed." + Remove-TransitionSimulation + } + } + + Invoke-BehaviorTest "explicit post-reboot trust reset republishes runtime then resumes" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $sim.DirectoryTrusted = $false + Assert-Throws { Invoke-SimulatedTransition } ` + "existing Agent directory was untrusted" + $sim.BootChanged = $true + $script:BlockerPresentBeforeRemoval = $false + Invoke-SimulatedTransition ` + -ApproveFilesystemTrustResetAfterReboot ` + -Checkpoint { + param($name) + if ($name -eq "before-trust-blocker-removal") { + $script:BlockerPresentBeforeRemoval = Test-Path ` + -LiteralPath $sim.TrustBlocker + } + } | Out-Null + Assert-True $script:BlockerPresentBeforeRemoval ` + "Parent removed the blocker before final convergence." + Assert-True (-not (Test-Path -LiteralPath $sim.TrustBlocker)) ` + "Approved post-reboot reset did not remove the external blocker." + Assert-True ( + $sim.Events -contains "trust-reset-runtime-publication" + ) "Trust reset did not publish fresh runtime before continuing." + Assert-True ($sim.Redemptions.Count -eq 1) ` + "Approved clean trust reset did not resume normal transition." + $publishIndex = $sim.Events.IndexOf("trust-reset-runtime-publication") + $restoreIndex = $sim.Events.IndexOf("task:True") + $removeIndex = $sim.Events.IndexOf( + "trust-blocker:removed-after-convergence" + ) + Assert-True ( + $publishIndex -ge 0 -and $restoreIndex -gt $publishIndex -and + $removeIndex -gt $restoreIndex + ) "Trust blocker removal did not follow runtime and task convergence." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "installer failure leaves trust blocker present" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $sim.DirectoryTrusted = $false + Assert-Throws { Invoke-SimulatedTransition } ` + "existing Agent directory was untrusted" + $sim.BootChanged = $true + $sim.InstallerFailure = $true + Assert-Throws { Invoke-SimulatedTransition ` + -ApproveFilesystemTrustResetAfterReboot } ` + "runtime publication failure" + Assert-True (Test-Path -LiteralPath $sim.TrustBlocker) ` + "Installer failure removed the trust blocker." + Assert-True ($sim.Redemptions.Count -eq 0) ` + "Installer failure reached credential redemption." + Assert-True ( + $sim.TrustResetAuthorized -and + $sim.TrustResetOriginalTaskRunning + ) "Installer failure lost the strictly validated task intent." + $sim.InstallerFailure = $false + Invoke-SimulatedTransition ` + -ApproveFilesystemTrustResetAfterReboot | Out-Null + Assert-True $sim.TaskRunning ` + "Installer-failure retry did not restore original running intent." + Assert-True (-not (Test-Path -LiteralPath $sim.TrustBlocker)) ` + "Installer-failure retry did not converge blocker removal." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "published reset marker resumes without early deletion" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $sim.DirectoryTrusted = $false + Assert-Throws { Invoke-SimulatedTransition } ` + "existing Agent directory was untrusted" + $sim.BootChanged = $true + Assert-Throws { + Invoke-SimulatedTransition ` + -ApproveFilesystemTrustResetAfterReboot ` + -Checkpoint { + param($name) + if ($name -eq "recovery-written") { + throw "stop-after-reset-publication" + } + } + } "stop-after-reset-publication" + Assert-True (Test-Path -LiteralPath $sim.TrustBlocker) ` + "Interrupted parent removed the trust blocker." + Assert-True $sim.TrustResetRuntimePublished ` + "Interrupted parent did not persist runtime publication state." + Invoke-SimulatedTransition ` + -ApproveFilesystemTrustResetAfterReboot | Out-Null + Assert-True (-not (Test-Path -LiteralPath $sim.TrustBlocker)) ` + "Resumed converged parent did not remove the blocker." + Assert-True ($sim.Redemptions.Count -eq 1) ` + "Resumed trust reset did not complete exactly one redemption." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "wrong task definition cannot remove trust blocker" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $sim.DirectoryTrusted = $false + Assert-Throws { Invoke-SimulatedTransition } ` + "existing Agent directory was untrusted" + $sim.BootChanged = $true + $sim.TaskDefinitionInvalid = $true + Assert-Throws { Invoke-SimulatedTransition ` + -ApproveFilesystemTrustResetAfterReboot } ` + "strict task definition" + Assert-True (Test-Path -LiteralPath $sim.TrustBlocker) ` + "Wrong task definition indirectly removed the trust blocker." + Assert-True ( + $sim.Events -notcontains "trust-reset-runtime-publication" + ) "Wrong task definition reached runtime publication." + Remove-TransitionSimulation + } + + foreach ($blockedTaskState in @("Disabled", "Unknown", "Queued")) { + Invoke-BehaviorTest "$blockedTaskState task cannot remove trust blocker" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $sim.DirectoryTrusted = $false + Assert-Throws { Invoke-SimulatedTransition } ` + "existing Agent directory was untrusted" + $sim.BootChanged = $true + $sim.TaskStateOverride = $blockedTaskState + Assert-Throws { Invoke-SimulatedTransition ` + -ApproveFilesystemTrustResetAfterReboot } ` + "no valid pre-reset intent" + Assert-True (Test-Path -LiteralPath $sim.TrustBlocker) ` + "$blockedTaskState task indirectly removed the trust blocker." + Remove-TransitionSimulation + } + } + + Invoke-BehaviorTest "directory is revalidated and simulated swap is blocked" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $sim.AttemptSwap = $true + Invoke-SimulatedTransition | Out-Null + Assert-True ( + $sim.Events -contains "directory-verified-before-active-move" -and + $sim.Events -contains "directory-verified-before-pending-move" + ) "Directory invariant was not revalidated before both renames." + Assert-True ( + $sim.Events -contains "swap-blocked-by-directory" + ) "Simulated untrusted pathname swap was not blocked." + $active = [IO.File]::ReadAllText($sim.Config) | ConvertFrom-Json + Assert-True ([string]$active.agent_secret -ne "legacy-shared-secret") ` + "Simulated swap prevented installation of the protected config." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "unknown replacement remnant is quarantined and blocks rerun" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $unknown = Join-Path $sim.Target "agent-config.json.unknown-replacement" + [IO.File]::WriteAllText($unknown, '{"agent_secret":"do-not-log-this"}') + $captured = & { + try { Invoke-SimulatedTransition } catch { $_.Exception.Message } + } *>&1 | Out-String + Assert-True (-not $captured.Contains("do-not-log-this")) ` + "Unknown remnant contents reached output." + Assert-True ($sim.Redemptions.Count -eq 0) ` + "Unknown replacement remnant reached redemption." + Assert-Throws { Invoke-SimulatedTransition } "evidence is quarantined" + Assert-True ($sim.Redemptions.Count -eq 0) ` + "Quarantined unknown remnant was later trusted." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "wrong committed active config never converges" { + $sim = New-TransitionSimulation -OriginallyRunning $true + Assert-Throws { + Invoke-SimulatedTransition -Checkpoint { + param($name) + if ($name -eq "pending-moved-to-active") { + throw "stop-after-active-install" + } + } + } "stop-after-active-install" + $active = [IO.File]::ReadAllText($sim.Config) | ConvertFrom-Json + $active.agent_id = "00000000-0000-4000-8000-000000009999" + [IO.File]::WriteAllText( + $sim.Config, + ($active | ConvertTo-Json -Depth 50) + [Environment]::NewLine + ) + $calls = $sim.Redemptions.Count + Assert-Throws { Invoke-SimulatedTransition } "does not match this Agent" + Assert-True ($sim.Redemptions.Count -eq $calls) ` + "Wrong active Agent identity reached redemption." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "malformed pending fails closed before redemption" { + $sim = New-TransitionSimulation -OriginallyRunning $true + Assert-Throws { + Invoke-SimulatedTransition -Checkpoint { + param($name) + if ($name -eq "pending-written") { throw "stop" } + } + } "stop" + [IO.File]::WriteAllText($sim.Pending, '{"agent_secret":') + $calls = $sim.Redemptions.Count + Assert-Throws { Invoke-SimulatedTransition } "malformed JSON" + Assert-True ($sim.Redemptions.Count -eq $calls) ` + "Malformed pending state reached redemption." + Remove-TransitionSimulation + } + + foreach ($mutation in @("agent", "secret")) { + Invoke-BehaviorTest "mismatched pending $mutation fails closed" { + $sim = New-TransitionSimulation -OriginallyRunning $true + Assert-Throws { + Invoke-SimulatedTransition -Checkpoint { + param($name) + if ($name -eq "pending-written") { throw "stop" } + } + } "stop" + $pending = [IO.File]::ReadAllText($sim.Pending) | ConvertFrom-Json + if ($mutation -eq "agent") { + $pending.agent_id = "00000000-0000-4000-8000-000000009999" + } + else { + $pending.agent_secret = "attacker-secret" + } + Set-SimPendingObject -Value $pending + $calls = $sim.Redemptions.Count + Assert-Throws { Invoke-SimulatedTransition } ` + "does not match protected recovery" + Assert-True ($sim.Redemptions.Count -eq $calls) ` + "Mismatched pending state reached redemption." + Remove-TransitionSimulation + } + } + + Invoke-BehaviorTest "insecure pending is quarantined and blocked on two runs" { + $sim = New-TransitionSimulation -OriginallyRunning $true + Assert-Throws { + Invoke-SimulatedTransition -Checkpoint { + param($name) + if ($name -eq "pending-written") { throw "stop" } + } + } "stop" + $sim.Events.Clear() + $sim.Secure[(Get-SimPathKey $sim.Pending)] = $false + $calls = $sim.Redemptions.Count + Assert-Throws { Invoke-SimulatedTransition } "evidence is quarantined" + Assert-True ( + $sim.Events -notcontains "read:agent-config.json.pending" + ) "Insecure pending contents were read." + Assert-True ($sim.Redemptions.Count -eq $calls) ` + "Insecure pending state reached redemption." + Assert-Throws { Invoke-SimulatedTransition } "evidence is quarantined" + Assert-True ($sim.Redemptions.Count -eq $calls) ` + "Second invocation used quarantined pending authority." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "task restart failure retains recovery and no success cleanup" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $sim.FailTaskRestore = $true + Assert-Throws { Invoke-SimulatedTransition } "task restart failure" + Assert-True (Test-Path $sim.Recovery) ` + "Recovery was deleted before task verification." + Assert-True (-not $sim.CleanupSafe) ` + "Cleanup was marked safe after restart failure." + $sim.FailTaskRestore = $false + Invoke-SimulatedTransition | Out-Null + Assert-True $sim.TaskRunning "Task recovery retry failed." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "strict task verification failure preserves committed recovery for retry" { + $sim = New-TransitionSimulation -OriginallyRunning $false + $sim.TaskVerificationFailureAfterCommit = $true + Assert-Throws { Invoke-SimulatedTransition } ` + "strict task verification failure" + Assert-True (Test-Path -LiteralPath $sim.Recovery) ` + "Recovery was deleted after strict final task verification failed." + Assert-True (-not $sim.CleanupSafe) ` + "Task verification failure reached success cleanup." + $recovery = Get-SimRecovery + $first = $sim.Redemptions[0] + $sim.TaskVerificationFailureAfterCommit = $false + Invoke-SimulatedTransition | Out-Null + Assert-True ($sim.Redemptions.Count -eq 2) ` + "Task-repair retry did not repeat idempotent redemption." + Assert-True ( + $sim.Redemptions[1].Token -eq $first.Token -and + $sim.Redemptions[1].SecretHash -eq $first.SecretHash -and + $sim.Redemptions[1].Token -eq $recovery.transition_token -and + $sim.Redemptions[1].SecretHash -eq $recovery.agent_secret_hash + ) "Task-repair retry changed the committed credential." + Assert-True (-not (Test-Path -LiteralPath $sim.Recovery)) ` + "Successful task-repair retry did not complete cleanup." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "cleanup interruption resumes from recovery token" { + $sim = New-TransitionSimulation -OriginallyRunning $true + Assert-Throws { + Invoke-SimulatedTransition -Checkpoint { + param($name) + if ($name -eq "before-recovery-cleanup") { + throw "cleanup-interrupted" + } + } + } "cleanup-interrupted" + Assert-True (-not (Test-Path $sim.Enrollment)) ` + "Enrollment token file should already be removed at this checkpoint." + Assert-True (Test-Path $sim.Recovery) ` + "Recovery must remain last during cleanup." + Invoke-SimulatedTransition | Out-Null + Assert-True (-not (Test-Path $sim.Recovery)) ` + "Recovery-token retry did not finish cleanup." + Assert-True $sim.TaskRunning ` + "Cleanup retry changed the intended task state." + Remove-TransitionSimulation + } + + Invoke-BehaviorTest "ordinary output and errors omit token and secret" { + $sim = New-TransitionSimulation -OriginallyRunning $true + $sim.ResponseMode = "lost-once" + $captured = & { + try { Invoke-SimulatedTransition } catch { $_.Exception.Message } + } *>&1 | Out-String + $recovery = Get-SimRecovery + Assert-True (-not $captured.Contains([string]$recovery.agent_secret)) ` + "Generated Agent secret appeared in output." + Assert-True (-not $captured.Contains([string]$recovery.transition_token)) ` + "Transition token appeared in output." + Remove-TransitionSimulation + } +} +finally { + Remove-TransitionSimulation +} + +Write-Host "$script:Passed credential-transition behavioral tests passed." diff --git a/agents/windows/tests/Test-MinerOpsEnrollment.ps1 b/agents/windows/tests/Test-MinerOpsEnrollment.ps1 index cd3dbd3..3db0ac5 100644 --- a/agents/windows/tests/Test-MinerOpsEnrollment.ps1 +++ b/agents/windows/tests/Test-MinerOpsEnrollment.ps1 @@ -32,6 +32,9 @@ function Assert-SourceContains { Assert-SourceContains 'enrollment\.json' ` "Bootstrap enrollment file is not explicit." +Assert-SourceContains 'workflow\s*-ne\s*"new_agent"' ` + "New-Agent bootstrap does not reject transition packages." + Assert-SourceContains '/api/agent/enroll' ` "Bootstrap does not use the secure enrollment endpoint." @@ -44,8 +47,20 @@ Assert-SourceContains 'agent-config\.json\.pending' ` Assert-SourceContains 'agent-config\.recovery\.json' ` "Post-redemption credential recovery path is missing." -Assert-SourceContains 'Protect-MinerOpsCredentialFile' ` - "Credential file ACL protection is missing." +Assert-SourceContains 'Open-MinerOpsEnrollmentCredentialFileCreateNew' ` + "Handle-based credential creation is missing." + +Assert-SourceContains 'FileMode\]::CreateNew' ` + "Credential files are not created with fail-if-present semantics." + +Assert-SourceContains 'FileShare\]::None' ` + "Credential files are not held exclusively while being created." + +Assert-SourceContains 'Flush\(\$true\)' ` + "Credential writes are not durably flushed through the trusted handle." + +Assert-SourceContains 'GetNumberOfLinks' ` + "Enrollment does not reject hard-linked credential targets." Assert-SourceContains 'S-1-5-18' ` "SYSTEM ACL entry is missing." @@ -53,12 +68,73 @@ Assert-SourceContains 'S-1-5-18' ` Assert-SourceContains 'S-1-5-32-544' ` "Administrators ACL entry is missing." -Assert-SourceContains 'WindowsIdentity\]::GetCurrent\(\)\.User' ` - "Installing user ACL entry is missing." +Assert-SourceContains 'SetOwner\(\$administrators\)' ` + "Stable Administrators ownership is missing from credential ACLs." -Assert-SourceContains 'Move-Item[\s\S]*\$ConfigPath' ` +Assert-SourceContains '\[IO\.File\]::Move\(\$PendingConfigPath, \$ConfigPath\)' ` "Atomic final config move is missing." +Assert-SourceContains 'Assert-MinerOpsEnrollmentDirectory' ` + "Enrollment does not revalidate the directory trust boundary." + +Assert-SourceContains 'minerops-filesystem-trust\.blocked-' ` + "Enrollment directory repair lacks an external durable trust blocker." + +Assert-SourceContains 'ApproveFilesystemTrustResetAfterReboot' ` + "Enrollment lacks an explicit post-reboot trust-reset gate." + +Assert-SourceContains 'windows-full-boot-event-v1:' ` + "Enrollment blockers do not use canonical full-kernel-boot records." + +if ($source -match 'LastBootUpTime') { + throw "Enrollment must not use a wall-clock boot timestamp." +} + +if ($source -match 'Remove-MinerOpsEnrollmentTrustBlocker') { + throw "Enrollment must not have independent trust-blocker deletion authority." +} + +Assert-SourceContains 'Enrollment cannot clear an existing filesystem trust blocker' ` + "Enrollment does not fail closed on retained filesystem trust blockers." + +Assert-SourceContains 'Assert-MinerOpsEnrollmentTrustedAncestors' ` + "Enrollment does not validate ancestor namespace protection." + +foreach ($functionName in @( + "Assert-MinerOpsEnrollmentNoReparsePath", + "Assert-MinerOpsEnrollmentTrustedAncestors" +)) { + $functionSource = [regex]::Match( + $source, + "(?s)function $functionName \{.*?\n\}" + ).Value + if ([string]::IsNullOrWhiteSpace($functionSource)) { + throw "$functionName source was not found." + } + if ($functionSource -notmatch '\$current\s*=\s*\$root') { + throw "$functionName does not preserve the filesystem root." + } + if ($functionSource -notmatch '\$fullPath\.Equals\(\s*\$root') { + throw "$functionName does not protect a root-valued full path from trimming." + } + if ($functionSource -match '\$root\.TrimEnd\(') { + throw "$functionName converts a root-qualified path to drive-relative form." + } + if ($functionSource -match 'GetFullPath\(\$Path\)\.TrimEnd\(') { + throw "$functionName trims the filesystem root before identifying it." + } + if ($functionName -eq "Assert-MinerOpsEnrollmentTrustedAncestors" -and + $functionSource -notmatch '\$parent\.Equals\(\s*\$root') { + throw "$functionName does not protect a root-valued parent from trimming." + } +} + +Assert-SourceContains 'Open-MinerOpsEnrollmentDirectoryLock' ` + "Enrollment does not lock an insecure target during repair." + +Assert-SourceContains 'DirectoryInfo\(\$staging\)' ` + "Enrollment does not securely stage a new directory." + Assert-SourceContains '& \$Installer' ` "Bootstrap does not hand off to the safe installer." @@ -68,6 +144,78 @@ Assert-SourceContains 'Start-ScheduledTask -TaskName "MinerOpsAgent"' ` Assert-SourceContains 'Remove-Item -LiteralPath \$EnrollmentPath' ` "Consumed extracted enrollment token is not removed." +if ($source -match '\[IO\.File\]::WriteAllText\(\s*\$(Pending|Recovery)ConfigPath') { + throw "Enrollment must not truncate or adopt a credential pathname." +} + +$createFunction = [regex]::Match( + $source, + '(?s)function Open-MinerOpsEnrollmentCredentialFileCreateNew \{.*?\n\}' +).Value +$createIndex = $createFunction.IndexOf( + 'New-MinerOpsEnrollmentProtectedFileCreateNewStream' +) +$verifyIndex = $createFunction.IndexOf('Test-MinerOpsEnrollmentCredentialAcl') +$linkIndex = $createFunction.IndexOf('Assert-MinerOpsEnrollmentSingleLinkStream') +if ( + $createIndex -lt 0 -or $verifyIndex -le $createIndex -or + $linkIndex -le $verifyIndex -or + $createFunction -match 'Set-MinerOpsEnrollmentStreamAcl' +) { + throw "Enrollment credential creation must apply FileSecurity at CreateNew, verify it, then reject links before content." +} + +$createStreamFunction = [regex]::Match( + $source, + '(?s)function New-MinerOpsEnrollmentProtectedFileCreateNewStream \{.*?\n\}' +).Value +foreach ($requiredCreateContract in @( + '\[IO\.FileStream\]::new', + 'FileMode\]::CreateNew', + 'FileSystemRights\]::WriteData', + 'FileSystemRights\]::ReadPermissions', + 'FileSystemRights\]::Synchronize', + 'FileShare\]::None', + '\$Acl' +)) { + if ($createStreamFunction -notmatch $requiredCreateContract) { + throw "Enrollment protected CreateNew stream contract is incomplete." + } +} +if ($createStreamFunction -match 'FullControl|SetAccessControl|Set-Acl') { + throw "Enrollment protected creation requests excess rights or repairs by path." +} + +$repairFunction = [regex]::Match( + $source, + '(?s)function Protect-MinerOpsEnrollmentExistingConfig \{.*?\n\}' +).Value +foreach ($requiredRepairRight in @( + 'ReadPermissions', + 'ChangePermissions', + 'TakeOwnership', + 'Synchronize' +)) { + if ($repairFunction -notmatch $requiredRepairRight) { + throw "Enrollment existing-config repair is missing $requiredRepairRight." + } +} +if ($repairFunction -match 'Set-Acl|FileAccess') { + throw "Enrollment existing-config repair must remain handle-bound." +} + +$writeFunction = [regex]::Match( + $source, + '(?s)function Write-MinerOpsEnrollmentCredentialJson \{.*?\n\}' +).Value +if ( + $writeFunction -notmatch 'Assert-MinerOpsEnrollmentSingleLinkStream' -or + $writeFunction -notmatch '\$Stream\.Write' -or + $writeFunction -notmatch '\$Stream\.Flush\(\$true\)' +) { + throw "Enrollment credential content is not written and flushed through the verified handle." +} + $existingConfigGuard = $source.IndexOf( 'Test-Path -LiteralPath $ConfigPath' ) @@ -85,7 +233,7 @@ if ( } $configMove = $source.IndexOf( - '-Destination $ConfigPath' + '[IO.File]::Move($PendingConfigPath, $ConfigPath)' ) $installerCall = $source.IndexOf( diff --git a/agents/windows/tests/Test-MinerOpsInstaller.ps1 b/agents/windows/tests/Test-MinerOpsInstaller.ps1 index b57b433..1ebabc1 100644 --- a/agents/windows/tests/Test-MinerOpsInstaller.ps1 +++ b/agents/windows/tests/Test-MinerOpsInstaller.ps1 @@ -33,7 +33,7 @@ if ($preflight -lt 0 -or $preflight -gt $firstTargetChange) { throw "Source preflight must happen before target changes." } -Assert-SourceContains 'if \(\$null -ne \(Get-MinerOpsScheduledTask' ` +Assert-SourceContains '\$existingTask\s*=\s*Get-MinerOpsScheduledTask' ` "Existing task preservation guard is missing." Assert-SourceContains 'Preserving existing Scheduled Task' ` "Existing task preservation is not explicit." @@ -49,6 +49,136 @@ Assert-SourceContains 'Unregister-ScheduledTask' ` "Rollback does not remove tasks created by a failed install." Assert-SourceContains 'could not restart restored MinerOpsAgent' ` "Rollback restart failure is not surfaced." +Assert-SourceContains 'Assert-MinerOpsInstallerTrustedAncestors' ` + "Installer does not validate ancestor namespace protection." + +foreach ($functionName in @( + "Assert-MinerOpsInstallerNoReparsePath", + "Assert-MinerOpsInstallerTrustedAncestors" +)) { + $functionSource = [regex]::Match( + $source, + "(?s)function $functionName \{.*?\n\}" + ).Value + if ([string]::IsNullOrWhiteSpace($functionSource)) { + throw "$functionName source was not found." + } + if ($functionSource -notmatch '\$current\s*=\s*\$root') { + throw "$functionName does not preserve the filesystem root." + } + if ($functionSource -notmatch '\$fullPath\.Equals\(\s*\$root') { + throw "$functionName does not protect a root-valued full path from trimming." + } + if ($functionSource -match '\$root\.TrimEnd\(') { + throw "$functionName converts a root-qualified path to drive-relative form." + } + if ($functionSource -match 'GetFullPath\(\$Path\)\.TrimEnd\(') { + throw "$functionName trims the filesystem root before identifying it." + } + if ($functionName -eq "Assert-MinerOpsInstallerTrustedAncestors" -and + $functionSource -notmatch '\$parent\.Equals\(\s*\$root') { + throw "$functionName does not protect a root-valued parent from trimming." + } +} + +Assert-SourceContains 'Open-MinerOpsInstallerDirectoryLock' ` + "Installer does not lock an insecure target against replacement." +Assert-SourceContains 'minerops-filesystem-trust\.blocked-' ` + "Installer directory repair lacks an external durable provenance blocker." +Assert-SourceContains 'ApproveFilesystemTrustResetAfterReboot' ` + "Installer lacks an explicit post-reboot trust-reset gate." +Assert-SourceContains 'DirectoryInfo\(\$staging\)' ` + "Installer does not stage a protected new directory before publication." +Assert-SourceContains 'Assert-MinerOpsInstallerDirectory' ` + "Installer does not read back the final directory security invariant." +Assert-SourceContains 'Protect-MinerOpsInstallerExistingConfig' ` + "Installer does not contain the existing active-config ACL." +Assert-SourceContains 'GetNumberOfLinks' ` + "Installer does not reject hard-linked runtime, config, or backup files." +Assert-SourceContains 'Assert-MinerOpsInstallerTrustedRuntimeFile' ` + "Installer does not verify existing runtime rollback provenance." +Assert-SourceContains 'Protect-MinerOpsInstallerRuntimeFile' ` + "Installer does not secure and verify published runtime and rollback copies." +Assert-SourceContains 'Pre-reset runtime is evidence, never rollback authority' ` + "Installer trust reset can adopt pre-reset runtime as rollback authority." +Assert-SourceContains 'parent-owned filesystem trust blocker remains' ` + "Installer does not explicitly retain the parent-owned trust blocker." +Assert-SourceContains 'windows-full-boot-event-v1:' ` + "Installer blockers do not use canonical full-kernel-boot records." +if ($source -match 'Remove-MinerOpsInstallerTrustBlocker|Remove-Item[^\r\n]*TrustBlocker') { + throw "Installer must have no filesystem trust-blocker deletion path." +} +if ($source -match 'LastBootUpTime') { + throw "Installer must not create wall-clock boot-epoch records." +} + +$createStreamFunction = [regex]::Match( + $source, + '(?s)function New-MinerOpsInstallerProtectedFileCreateNewStream \{.*?\n\}' +).Value +foreach ($requiredCreateContract in @( + '\[IO\.FileStream\]::new', + 'FileMode\]::CreateNew', + 'FileSystemRights\]::WriteData', + 'FileSystemRights\]::ReadPermissions', + 'FileSystemRights\]::Synchronize', + 'FileShare\]::None', + '\$Acl' +)) { + if ($createStreamFunction -notmatch $requiredCreateContract) { + throw "Installer protected CreateNew stream contract is incomplete." + } +} +if ($createStreamFunction -match 'FullControl|SetAccessControl|Set-Acl') { + throw "Installer protected creation requests excess rights or repairs by path." +} + +$trustBlockerFunction = [regex]::Match( + $source, + '(?s)function New-MinerOpsInstallerTrustBlocker \{.*?\n\}' +).Value +$trustCreateIndex = $trustBlockerFunction.IndexOf( + 'New-MinerOpsInstallerProtectedFileCreateNewStream' +) +$trustVerifyIndex = $trustBlockerFunction.IndexOf( + 'Test-MinerOpsInstallerProtectedFileAcl' +) +$trustContentIndex = $trustBlockerFunction.IndexOf('$record =') +if ( + $trustCreateIndex -lt 0 -or + $trustVerifyIndex -le $trustCreateIndex -or + $trustContentIndex -le $trustVerifyIndex +) { + throw "Installer trust blockers must be protected and verified before content." +} + +$configRepairFunction = [regex]::Match( + $source, + '(?s)function Protect-MinerOpsInstallerExistingConfig \{.*?\n\}' +).Value +foreach ($requiredRepairRight in @( + 'ReadPermissions', + 'ChangePermissions', + 'TakeOwnership', + 'Synchronize' +)) { + if ($configRepairFunction -notmatch $requiredRepairRight) { + throw "Installer existing-config repair is missing $requiredRepairRight." + } +} +if ($configRepairFunction -match 'Set-Acl|FileAccess') { + throw "Installer existing-config repair must remain handle-bound." +} + +$repairFunction = [regex]::Match( + $source, + '(?s)function Initialize-MinerOpsInstallerDirectory \{.*?\n\}' +).Value +$repairIndex = $repairFunction.IndexOf('Set-Acl -LiteralPath $fullPath') +$abortIndex = $repairFunction.IndexOf('this invocation will not back up') +if ($repairIndex -lt 0 -or $abortIndex -le $repairIndex) { + throw "Insecure existing-directory containment must abort before publication." +} if ($source -match 'Register-ScheduledTask[^\r\n]*-Force') { throw "Installer must not force-overwrite Scheduled Tasks." diff --git a/agents/windows/tests/Test-MinerOpsNativeHarness.ps1 b/agents/windows/tests/Test-MinerOpsNativeHarness.ps1 new file mode 100644 index 0000000..b1612d5 --- /dev/null +++ b/agents/windows/tests/Test-MinerOpsNativeHarness.ps1 @@ -0,0 +1,582 @@ +$ErrorActionPreference = "Stop" + +$supportPath = Join-Path $PSScriptRoot "MinerOpsNativeHarnessSupport.ps1" +. $supportPath + +function Assert-True { + param( + [Parameter(Mandatory = $true)][bool]$Condition, + [Parameter(Mandatory = $true)][string]$Message + ) + if (-not $Condition) { throw $Message } +} + +function Assert-Throws { + param( + [Parameter(Mandatory = $true)][scriptblock]$Action, + [Parameter(Mandatory = $true)][string]$Pattern + ) + + try { + & $Action | Out-Null + } + catch { + if ($_.Exception.Message -notmatch $Pattern) { + throw "Expected '$Pattern', got '$($_.Exception.Message)'." + } + return + } + throw "Expected action to fail with '$Pattern'." +} + +$script:Passed = 0 +function Invoke-HarnessTest { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][scriptblock]$Action + ) + + & $Action + $script:Passed++ + Write-Host "PASS: $Name" +} + +function New-PassingSwapPostconditions { + return [pscustomobject]@{ + TargetExists = $true + TargetIdentityUnchanged = $true + TargetContentUnchanged = $true + TargetAclTrusted = $true + TargetAclUnchanged = $true + TargetOwnerTrusted = $true + TargetOwnerUnchanged = $true + TargetSingleLink = $true + SourceExists = $true + SourceIdentityUnchanged = $true + SourceContentUnchanged = $true + AttackerSourceNotPromoted = $true + TargetDeniedStandardUserRead = $true + TargetDeniedStandardUserModify = $true + TargetDeniedStandardUserDelete = $true + TargetDeniedStandardUserRename = $true + NoAttackerReadableBackup = $true + } +} + +function New-ReadySwapFixture { + return [pscustomobject]@{ + SourceExists = $true + TargetExists = $true + PathsDistinct = $true + SourceCreatedByStandardUser = $true + SourceOwnedByStandardUser = $true + SourceReadable = $true + SourceModifiable = $true + SourceRenameable = $true + SourceDeleteCapable = $true + SourceReplaceAccessVerified = $true + TargetAclTrusted = $true + TargetOwnerTrusted = $true + TargetIdentityCaptured = $true + SourceIdentityCaptured = $true + FileIdentitiesDistinct = $true + BackupArgumentValid = $true + BackupPathsDistinct = $true + BackupExpectedStateVerified = $true + BackupConfidentialitySafe = $true + SameVolume = $true + NtfsVolume = $true + } +} + +function New-SwapResult { + param([Parameter(Mandatory = $true)][string]$Status) + return [pscustomobject]@{ + operation = "swap" + status = $Status + exception_type = "" + hresult = 0 + outer_exception_type = "" + outer_hresult = 0 + leaf_exception_type = "" + leaf_hresult = 0 + native_error = $null + exception_chain_types = @() + replace_invoked = $true + } +} + +Invoke-HarnessTest "helper classifies success and status exit codes" { + Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "succeeded") -eq 0) ` + "Success exit code changed." + Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "security_denied") -eq 5) ` + "Security-denial exit code changed." + Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "fixture_error") -eq 6) ` + "Fixture-error exit code changed." + Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "unexpected_failure") -eq 7) ` + "Unexpected-failure exit code changed." + Assert-True ((Get-MinerOpsNativeStatusExitCode ` + -Status "security_denied_requires_postconditions") -eq 8) ` + "Postcondition-gated security exit code changed." + Assert-True ((Get-MinerOpsNativeStatusExitCode ` + -Status "api_precondition_failure") -eq 9) ` + "API-precondition exit code changed." +} + +Invoke-HarnessTest "helper distinguishes security, fixture, and unexpected failures" { + $denied = [UnauthorizedAccessException]::new("denied") + Assert-True ((Get-MinerOpsNativeExceptionStatus ` + -Operation read -Exception $denied) -ceq "security_denied") ` + "UnauthorizedAccessException was not a security denial." + + $unableToRemove = [IO.IOException]::new("expected", -2147023721) + Assert-True ((Get-MinerOpsNativeExceptionStatus ` + -Operation swap -Exception $unableToRemove) -ceq + "security_denied_requires_postconditions") ` + "ERROR_UNABLE_TO_REMOVE_REPLACED was not postcondition-gated." + + $missing = [IO.FileNotFoundException]::new("missing") + Assert-True ((Get-MinerOpsNativeExceptionStatus ` + -Operation swap -Exception $missing) -ceq "fixture_error") ` + "Missing swap source was not a fixture error." + + $invalidArgument = [ArgumentException]::new("illegal backup path") + Assert-True ($invalidArgument.HResult -eq -2147024809) ` + "ArgumentException no longer carries 0x80070057." + Assert-True ((Get-MinerOpsNativeExceptionStatus ` + -Operation swap -Exception $invalidArgument) -ceq "fixture_error") ` + "ERROR_INVALID_PARAMETER-like argument failure was not a fixture error." + + $crossVolume = [IO.IOException]::new("different volume", -2147024879) + Assert-True ((Get-MinerOpsNativeExceptionStatus ` + -Operation swap -Exception $crossVolume) -ceq + "api_precondition_failure") ` + "Cross-volume swap was not an API-precondition failure." + + $partialMove = [IO.IOException]::new("unexpected", -2147023720) + Assert-True ((Get-MinerOpsNativeExceptionStatus ` + -Operation swap -Exception $partialMove) -ceq "unexpected_failure") ` + "Partial ReplaceFile failure was accepted." +} + +Invoke-HarnessTest "helper unwraps MethodInvocationException security denial" { + $denied = [UnauthorizedAccessException]::new("denied") + $wrapped = [Management.Automation.MethodInvocationException]::new( + "method invocation failed", + $denied + ) + Assert-True ((Get-MinerOpsNativeExceptionStatus ` + -Operation read -Exception $wrapped) -ceq "security_denied") ` + "Wrapped UnauthorizedAccessException was not a security denial." +} + +Invoke-HarnessTest "helper unwraps multiple PowerShell exception layers" { + $denied = [UnauthorizedAccessException]::new("denied") + $methodWrapper = [Management.Automation.MethodInvocationException]::new( + "method invocation failed", + $denied + ) + $runtimeWrapper = [Management.Automation.RuntimeException]::new( + "runtime failed", + $methodWrapper + ) + Assert-True ((Get-MinerOpsNativeExceptionStatus ` + -Operation read -Exception $runtimeWrapper) -ceq "security_denied") ` + "Nested PowerShell wrappers were not unwrapped." +} + +Invoke-HarnessTest "helper decodes only HRESULT_FROM_WIN32 values" { + Assert-True ((Get-MinerOpsNativeWin32Error -HResult -2147024891) -eq 5) ` + "0x80070005 did not decode as ERROR_ACCESS_DENIED." + $accessDenied = [IO.IOException]::new("access denied", -2147024891) + Assert-True ((Get-MinerOpsNativeExceptionStatus ` + -Operation read -Exception $accessDenied) -ceq "security_denied") ` + "Win32-derived ERROR_ACCESS_DENIED was not security evidence." + Assert-True ($null -eq ( + Get-MinerOpsNativeWin32Error -HResult -2146233087 + )) "CLR HRESULT 0x80131501 produced a bogus native error." +} + +Invoke-HarnessTest "unrelated wrapped IOException is not a security denial" { + $ioFailure = [IO.IOException]::new("unrelated") + $wrapped = [Management.Automation.MethodInvocationException]::new( + "method invocation failed", + $ioFailure + ) + Assert-True ((Get-MinerOpsNativeExceptionStatus ` + -Operation read -Exception $wrapped) -ceq "unexpected_failure") ` + "Unrelated wrapped IOException was accepted as security evidence." +} + +Invoke-HarnessTest "wrapped fixture and precondition failures stay distinct" { + $missing = [IO.FileNotFoundException]::new("missing") + $wrappedMissing = [Management.Automation.MethodInvocationException]::new( + "method invocation failed", + $missing + ) + Assert-True ((Get-MinerOpsNativeExceptionStatus ` + -Operation swap -Exception $wrappedMissing) -ceq "fixture_error") ` + "Wrapped fixture error changed classification." + + $crossVolume = [IO.IOException]::new("different volume", -2147024879) + $wrappedCrossVolume = [Reflection.TargetInvocationException]::new( + "target invocation failed", + $crossVolume + ) + Assert-True ((Get-MinerOpsNativeExceptionStatus ` + -Operation swap -Exception $wrappedCrossVolume) -ceq + "api_precondition_failure") ` + "Wrapped API precondition changed classification." +} + +Invoke-HarnessTest "exception diagnostics report outer leaf and chain types" { + $denied = [UnauthorizedAccessException]::new("denied") + $wrapped = [Management.Automation.MethodInvocationException]::new( + "method invocation failed", + $denied + ) + $details = Get-MinerOpsNativeExceptionDetails -Exception $wrapped + Assert-True ($details.OuterExceptionType -ceq + "System.Management.Automation.MethodInvocationException") ` + "Exception diagnostics recorded the wrong outer type." + Assert-True ($details.LeafExceptionType -ceq + "System.UnauthorizedAccessException") ` + "Exception diagnostics recorded the wrong leaf type." + Assert-True ($details.ExceptionChainTypes.Count -eq 2) ` + "Exception diagnostics recorded the wrong chain length." + Assert-True (-not $details.ExceptionChainTruncated) ` + "A short exception chain was reported as truncated." +} + +Invoke-HarnessTest "exception diagnostics handle null InnerException" { + $wrapperOnly = [Management.Automation.RuntimeException]::new( + "wrapper without inner exception" + ) + $details = Get-MinerOpsNativeExceptionDetails -Exception $wrapperOnly + Assert-True ($details.OuterExceptionType -ceq $details.LeafExceptionType) ` + "Null InnerException did not leave the wrapper as the leaf." + Assert-True ($details.ExceptionChainTypes.Count -eq 1) ` + "Null InnerException produced an invalid chain." +} + +Invoke-HarnessTest "exception-chain traversal is bounded defensively" { + [Exception]$deep = [UnauthorizedAccessException]::new("denied") + foreach ($level in 1..40) { + $deep = [Exception]::new("wrapper $level", $deep) + } + $details = Get-MinerOpsNativeExceptionDetails -Exception $deep + Assert-True ($details.ExceptionChainTypes.Count -eq 32) ` + "Deep exception traversal did not stop at its bound." + Assert-True ([bool]$details.ExceptionChainTruncated) ` + "Deep exception traversal did not report truncation." + Assert-True ((Get-MinerOpsNativeExceptionStatus ` + -Operation read -Exception $deep) -ceq "unexpected_failure") ` + "Truncated exception evidence did not fail closed." +} + +Invoke-HarnessTest "swap probe parses and passes an explicit native null" { + $probePath = Join-Path $PSScriptRoot ` + "Invoke-MinerOpsStandardUserProbe.ps1" + $tokens = $null + $parseErrors = $null + [Management.Automation.Language.Parser]::ParseFile( + $probePath, + [ref]$tokens, + [ref]$parseErrors + ) | Out-Null + Assert-True ($parseErrors.Count -eq 0) ` + "Standard-user probe has parser errors." + $probeSource = [IO.File]::ReadAllText($probePath) + Assert-True ($probeSource -match '\[NullString\]::Value') ` + "Swap probe does not pass an explicit native null backup argument." + Assert-True ($probeSource -notmatch + 'File\]::Replace\([^\)]*\$null') ` + "Swap probe regressed to a bare PowerShell null backup argument." + Assert-True ($probeSource -match + 'backup-producing swap fixtures are prohibited') ` + "Swap probe no longer prohibits confidentiality-unsafe backups." +} + +Invoke-HarnessTest "NullString preserves native null for a .NET string" { + if (-not ("MinerOpsNativeHarnessNullStringProbe" -as [type])) { + Add-Type -TypeDefinition @' +public static class MinerOpsNativeHarnessNullStringProbe +{ + public static bool IsNull(string value) + { + return value == null; + } +} +'@ + } + Assert-True (-not ( + [MinerOpsNativeHarnessNullStringProbe]::IsNull($null) + )) "A bare PowerShell null was not coerced to String.Empty." + Assert-True ( + [MinerOpsNativeHarnessNullStringProbe]::IsNull([NullString]::Value) + ) "NullString did not preserve native null for a .NET string." +} + +Invoke-HarnessTest "same source and destination is a fixture failure" { + $fixture = New-ReadySwapFixture + $fixture.PathsDistinct = $false + $classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture + Assert-True ($classification.Status -ceq "fixture_error") ` + "Equal source and destination paths were accepted." +} + +Invoke-HarnessTest "missing source is a fixture failure" { + $fixture = New-ReadySwapFixture + $fixture.SourceExists = $false + $classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture + Assert-True ($classification.Status -ceq "fixture_error") ` + "Missing source was accepted." +} + +Invoke-HarnessTest "missing target is a fixture failure" { + $fixture = New-ReadySwapFixture + $fixture.TargetExists = $false + $classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture + Assert-True ($classification.Status -ceq "fixture_error") ` + "Missing target was accepted." +} + +Invoke-HarnessTest "invalid backup argument is a fixture failure" { + foreach ($property in @( + "BackupArgumentValid", + "BackupPathsDistinct", + "BackupExpectedStateVerified", + "BackupConfidentialitySafe" + )) { + $fixture = New-ReadySwapFixture + $fixture.$property = $false + $classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture + Assert-True ($classification.Status -ceq "fixture_error") ` + "Invalid backup property $property was accepted." + } +} + +Invoke-HarnessTest "cross-volume limitation is not a security pass" { + $fixture = New-ReadySwapFixture + $fixture.SameVolume = $false + $classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture + Assert-True ($classification.Status -ceq "api_precondition_failure") ` + "Cross-volume fixture was not an API-precondition failure." +} + +Invoke-HarnessTest "valid controlled same-volume source is attack-ready" { + $classification = Get-MinerOpsNativeSwapFixtureStatus ` + -Fixture (New-ReadySwapFixture) + Assert-True ($classification.Status -ceq "ready") ` + "Valid same-volume fixture was not attack-ready." +} + +Invoke-HarnessTest "source ownership and control are mandatory" { + foreach ($property in @( + "SourceCreatedByStandardUser", + "SourceOwnedByStandardUser", + "SourceReadable", + "SourceModifiable", + "SourceRenameable", + "SourceDeleteCapable", + "SourceReplaceAccessVerified" + )) { + $fixture = New-ReadySwapFixture + $fixture.$property = $false + $classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture + Assert-True ($classification.Status -ceq "fixture_error") ` + "Missing source-control proof $property was accepted." + } +} + +Invoke-HarnessTest "invalid swap fixture cannot pass as security" { + Assert-Throws -Pattern "fixture was invalid" -Action { + Assert-MinerOpsNativeSwapSecurityOutcome ` + -Result (New-SwapResult -Status "fixture_error") ` + -Postconditions (New-PassingSwapPostconditions) + } +} + +Invoke-HarnessTest "unrelated swap API precondition cannot pass as security" { + Assert-Throws -Pattern "unrelated API precondition" -Action { + Assert-MinerOpsNativeSwapSecurityOutcome ` + -Result (New-SwapResult -Status "api_precondition_failure") ` + -Postconditions (New-PassingSwapPostconditions) + } +} + +Invoke-HarnessTest "successful replacement is a security failure" { + Assert-Throws -Pattern "completed the protected replacement" -Action { + Assert-MinerOpsNativeSwapSecurityOutcome ` + -Result (New-SwapResult -Status "succeeded") ` + -Postconditions (New-PassingSwapPostconditions) + } +} + +Invoke-HarnessTest "security denial passes only with all postconditions" { + Assert-MinerOpsNativeSwapSecurityOutcome ` + -Result (New-SwapResult -Status "security_denied") ` + -Postconditions (New-PassingSwapPostconditions) + Assert-MinerOpsNativeSwapSecurityOutcome ` + -Result (New-SwapResult ` + -Status "security_denied_requires_postconditions") ` + -Postconditions (New-PassingSwapPostconditions) + + $failed = New-PassingSwapPostconditions + $failed.TargetAclTrusted = $false + Assert-Throws -Pattern "target ACL is not trusted" -Action { + Assert-MinerOpsNativeSwapSecurityOutcome ` + -Result (New-SwapResult -Status "security_denied") ` + -Postconditions $failed + } +} + +Invoke-HarnessTest "denial with changed target identity is a security failure" { + $failed = New-PassingSwapPostconditions + $failed.TargetIdentityUnchanged = $false + Assert-Throws -Pattern "target identity changed" -Action { + Assert-MinerOpsNativeSwapSecurityOutcome ` + -Result (New-SwapResult -Status "security_denied") ` + -Postconditions $failed + } +} + +Invoke-HarnessTest "denial with changed target content is a security failure" { + $failed = New-PassingSwapPostconditions + $failed.TargetContentUnchanged = $false + Assert-Throws -Pattern "target content changed" -Action { + Assert-MinerOpsNativeSwapSecurityOutcome ` + -Result (New-SwapResult -Status "security_denied") ` + -Postconditions $failed + } +} + +Invoke-HarnessTest "denial with changed ACL or owner is a security failure" { + foreach ($property in @( + "TargetAclTrusted", + "TargetAclUnchanged", + "TargetOwnerTrusted", + "TargetOwnerUnchanged" + )) { + $failed = New-PassingSwapPostconditions + $failed.$property = $false + Assert-Throws -Pattern "target (ACL|owner)" -Action { + Assert-MinerOpsNativeSwapSecurityOutcome ` + -Result (New-SwapResult -Status "security_denied") ` + -Postconditions $failed + } + } +} + +Invoke-HarnessTest "attacker-readable protected backup is a security failure" { + $failed = New-PassingSwapPostconditions + $failed.NoAttackerReadableBackup = $false + Assert-Throws -Pattern "through a backup" -Action { + Assert-MinerOpsNativeSwapSecurityOutcome ` + -Result (New-SwapResult -Status "security_denied") ` + -Postconditions $failed + } +} + +Invoke-HarnessTest "denial must record that File.Replace was reached" { + $result = New-SwapResult -Status "security_denied" + $result.replace_invoked = $false + Assert-Throws -Pattern "did not reach System.IO.File.Replace" -Action { + Assert-MinerOpsNativeSwapSecurityOutcome ` + -Result $result -Postconditions (New-PassingSwapPostconditions) + } +} + +$initialState = Get-MinerOpsNativeGetItemState +try { + Invoke-HarnessTest "trace leaves no global function when none existed" { + $childScript = Join-Path $PSScriptRoot ` + "Test-MinerOpsNativeHarnessGetItemState.ps1" + $powerShell = (Get-Process -Id $PID).Path + & $powerShell -NoLogo -NoProfile -File $childScript ` + -Mode NoExistingFunction -SupportPath $supportPath + Assert-True ($LASTEXITCODE -eq 0) ` + "No-existing-function child test failed." + } + + Invoke-HarnessTest "trace restores an existing global function exactly" { + $childScript = Join-Path $PSScriptRoot ` + "Test-MinerOpsNativeHarnessGetItemState.ps1" + $powerShell = (Get-Process -Id $PID).Path + & $powerShell -NoLogo -NoProfile -File $childScript ` + -Mode ExistingFunction -SupportPath $supportPath + Assert-True ($LASTEXITCODE -eq 0) ` + "Existing-function child test failed." + } + + Invoke-HarnessTest "trace cleanup survives Action failure" { + $before = Get-MinerOpsNativeGetItemState + Assert-Throws -Pattern "injected trace failure" -Action { + Invoke-MinerOpsNativeGetItemTrace -Action { + Get-Item -LiteralPath $supportPath | Out-Null + throw "injected trace failure" + } + } + Assert-MinerOpsNativeGetItemState -Expected $before ` + -Context "throwing trace action" + } + + Invoke-HarnessTest "trace cleanup survives a later assertion failure" { + $before = Get-MinerOpsNativeGetItemState + try { + [void](Invoke-MinerOpsNativeGetItemTrace -Action { + Get-Item -LiteralPath $supportPath + }) + throw "injected post-trace assertion failure" + } + catch { + Assert-True ( + $_.Exception.Message -match "post-trace assertion failure" + ) "Unexpected post-trace failure." + } + Assert-MinerOpsNativeGetItemState -Expected $before ` + -Context "post-trace assertion failure" + } + + Invoke-HarnessTest "multiple sequential traces preserve command state" { + $before = Get-MinerOpsNativeGetItemState + foreach ($iteration in 1..3) { + $observed = @(Invoke-MinerOpsNativeGetItemTrace -Action { + Get-Item -LiteralPath $supportPath + }) + Assert-True ($observed.Count -eq 1) ` + "Sequential trace $iteration recorded the wrong count." + Assert-MinerOpsNativeGetItemState -Expected $before ` + -Context "sequential trace $iteration" + } + } + + Invoke-HarnessTest "simulated top-level failure restores Get-Item" { + $before = Get-MinerOpsNativeGetItemState + try { + try { + [void](Invoke-MinerOpsNativeGetItemTrace -Action { + Get-Item -LiteralPath $supportPath + }) + throw "injected top-level harness failure" + } + finally { + Assert-MinerOpsNativeGetItemState -Expected $before ` + -Context "simulated harness final cleanup" + } + } + catch { + Assert-True ( + $_.Exception.Message -match "top-level harness failure" + ) "Unexpected simulated top-level failure." + } + Assert-MinerOpsNativeGetItemState -Expected $before ` + -Context "after simulated top-level failure" + } +} +finally { + Assert-MinerOpsNativeGetItemState -Expected $initialState ` + -Context "portable native-harness tests final cleanup" +} + +Write-Host "$Passed native-harness regression tests passed." diff --git a/agents/windows/tests/Test-MinerOpsNativeHarnessGetItemState.ps1 b/agents/windows/tests/Test-MinerOpsNativeHarnessGetItemState.ps1 new file mode 100644 index 0000000..8802b61 --- /dev/null +++ b/agents/windows/tests/Test-MinerOpsNativeHarnessGetItemState.ps1 @@ -0,0 +1,48 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet("NoExistingFunction", "ExistingFunction")] + [string]$Mode, + [Parameter(Mandatory = $true)][string]$SupportPath +) + +$ErrorActionPreference = "Stop" +. $SupportPath + +if ($Mode -ceq "NoExistingFunction") { + $before = Get-MinerOpsNativeGetItemState + if ([bool]$before.FunctionExisted) { + throw "Child process unexpectedly began with a global Get-Item function." + } + $observed = @(Invoke-MinerOpsNativeGetItemTrace -Action { + Get-Item -LiteralPath $SupportPath + }) + if ($observed -cnotcontains $SupportPath) { + throw "Trace did not observe Get-Item." + } + Assert-MinerOpsNativeGetItemState -Expected $before ` + -Context "no-preexisting-function child test" + return +} + +$existing = { param($LiteralPath) "preexisting:$LiteralPath" } +Set-Item -LiteralPath "Function:\global:Get-Item" -Value $existing -Force +$before = Get-MinerOpsNativeGetItemState +$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action { + Get-Item -LiteralPath $SupportPath +}) +if ($observed -cnotcontains $SupportPath) { + throw "Trace did not observe Get-Item over an existing global function." +} +$after = Get-MinerOpsNativeGetItemState +Assert-MinerOpsNativeGetItemState -Expected $before ` + -Context "preexisting-function child test" +if ($after.FunctionScriptBlock.ToString() -cne $existing.ToString()) { + throw "Existing Get-Item ScriptBlock was not restored exactly." +} +if (-not [object]::ReferenceEquals( + $after.FunctionScriptBlock, + $before.FunctionScriptBlock +)) { + throw "Existing Get-Item ScriptBlock instance changed." +} diff --git a/agents/windows/tests/WINDOWS_CREDENTIAL_TRANSITION_VALIDATION.md b/agents/windows/tests/WINDOWS_CREDENTIAL_TRANSITION_VALIDATION.md new file mode 100644 index 0000000..1ecf039 --- /dev/null +++ b/agents/windows/tests/WINDOWS_CREDENTIAL_TRANSITION_VALIDATION.md @@ -0,0 +1,305 @@ +# Disposable Windows filesystem-trust validation + +Native Windows evidence is required before deployment. The cross-platform +behavioral harness proves deterministic state-machine decisions; it cannot +prove NTFS access checks, directory-handle sharing, junction behavior, +same-directory rename semantics, or Scheduled Task behavior. + +## Isolation + +Use a newly created, disposable Windows 10/11 or Windows Server VM. Do not join +it to a production domain or connect it to a production MinerOps Hub, database, +or miner network. Never use a production package, URL, Agent ID, transition +token, shared secret, or API3 credential. Copy only the candidate +`agents\windows` directory to the VM and take a snapshot first. + +Run 64-bit Windows PowerShell 5.1 as Administrator: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +.\tests\Test-MinerOpsCredentialTransition.Windows.ps1 ` + -AcknowledgeDisposableVm +``` + +Record the exact pass count printed by the candidate harness. Do not claim the +native suite passed unless this command actually completed on Windows. Save the +complete transcript plus: + +```powershell +Get-ComputerInfo | Select-Object WindowsProductName, WindowsVersion, OsBuildNumber +$PSVersionTable +``` + +The native primitive suite verifies: + +- protected credential, enrollment, installer, and trust-blocker files are + created with their final `FileSecurity`, held exclusively, and read back + through the same still-empty handle before any content is written; +- the directory and credential-file DACLs deny standard-user read, create, + delete, rename, and replacement-swap operations; +- ACL setup or read-back failure cannot leave secret-bearing content; +- a pre-created pathname remains unchanged when `CreateNew` fails; +- an insecure artifact is repaired through an exclusive handle with explicit + security rights, then rejected; inability to acquire those rights fails + closed without a path-based fallback; +- the durable blocker exists before an ACL-repaired artifact could be + reconsidered, and remains effective on the next check; +- quarantine evidence is unreadable to the standard user; +- the two-rename install leaves the new active file and a non-authoritative + prior remnant, and an active-missing install does not invent prior authority; +- a directory junction is rejected; +- a permissive directory is hardened, gets an external durable blocker, and + cannot continue in that process or an ordinary rerun; +- pre-repair standard-user file and directory handles are exercised after ACL + repair while automatic transition remains blocked regardless of whether the + handle operation succeeds; +- hard-linked enrollment, runtime, and backup targets are rejected; and +- a real harmless expected Scheduled Task is positively verified for exact + `Running` and `Ready`, while missing, query failure, disabled, malformed, + wrong-action, and wrong-principal cases fail closed. + +## Full-kernel-boot epoch matrix + +The source uses the canonical value +`windows-full-boot-event-v1:<16 lowercase hex digits>`. The payload is the +Event Log `RecordId` anchor of the newest System-log event from +`Microsoft-Windows-Kernel-Boot`, event ID 27, whose namespace-aware XML payload +contains exactly one canonical `BootType` value equal to `0`. `ProviderName`, +`Id`, nullable `RecordId`, and nullable `Version` must have the real typed +EventLogRecord shapes; supported versions are 0 and 1. The record ID is not an +immutable boot GUID. A reset requires a valid current anchor strictly greater +than the stored anchor. Equal, lower/restored/reused, missing, and ambiguous +anchors fail closed. The implementation deliberately does not use wall-clock +time or the Windows `BootId` counter: Fast Startup is a hibernated kernel +restoration and must not authorize reset merely because Windows reports a new +startup sequence. + +On a disposable VM, dot-source the transition script and record +`Get-MinerOpsBootIdentifier` before and after every row. Preserve the raw event +object and `ToXml()` output from `Get-WinEvent`. For each event, record the +runtime types and values of `ProviderName`, `Id`, `RecordId`, and `Version`, the +root namespace, and the `System/EventData/Data[@Name='BootType']` shape. + +| Transition | Required canonical identity | +| --- | --- | +| Change system clock within the running session | unchanged | +| Change timezone within the running session | unchanged | +| Sleep and resume | unchanged | +| Hibernate and resume, where supported | unchanged | +| Shutdown and power-on with Fast Startup, where supported | unchanged | +| Restart from the Windows power menu | strictly greater record anchor | + +For the Fast Startup row, independently confirm that Windows restored kernel +session 0 and that the latest event 27 has a nonzero `BootType`; the newest +full-boot (`BootType=0`) event record must remain the same. For the Restart row, +confirm a new exact `BootType=0` event and a new canonical identity. Also test a +missing System log/provider event, duplicated `BootType`, malformed XML value, +wrong namespace, unsupported/null version, wrong provider/event ID, +zero/malformed record ID, and query access failure; every case must fail +closed. + +Use a separate disposable snapshot for log-retention behavior. Preserve the +pre-clear stored anchor and evidence first, then clear only the disposable VM's +System log. Verify that missing history cannot authorize reset. After a full +Restart, if Event Log numbering restarts at or below the stored anchor, verify +that automatic reset remains blocked and requires operator intervention. Also +restore a snapshot exposing only an older valid full-boot event and confirm the +lower record ID is rejected. Do not add a timestamp fallback for any row. A +native result is required because EventLogRecord property types, provider +namespace/version, log-clear numbering, and power-state behavior cannot be +proven by the Linux harness. + +## Loopback workflow fixture + +Use a fresh snapshot for full workflow testing. All values must be disposable. + +1. Create a test `C:\MinerOpsAgent\agent-config.json` containing random Agent, + Site, and Zone UUIDs plus placeholder secrets. +2. Place a harmless `MinerOpsAgentService.ps1` in the target and register + `MinerOpsAgent` with the exact installer-controlled SYSTEM/highest action: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File + "C:\MinerOpsAgent\MinerOpsAgentService.ps1"`. +3. Serve a loopback-only mock at `http://127.0.0.1:`. Its + `POST /api/agent/credential-transition` handler records only the token hash + and submitted `agent_secret_hash`, returns the fixed disposable + Agent/Site/Zone identity, returns the same response for the same token and + hash, and rejects a changed hash. +4. Build a local package with that loopback origin and a random disposable + 43-character token. Never download from or call a real Hub. +5. For each case, restore the snapshot or recreate the fixture. Run once with + the task initially running and once with it intentionally stopped. + +The child-process shape for hard termination is: + +```powershell +$checkpoint = 'pending-written' +$code = @" +. 'C:\validation\agents\windows\Upgrade-MinerOpsAgentCredential.ps1' +Invoke-MinerOpsAgentCredentialTransition ` + -TargetRoot 'C:\MinerOpsAgent' ` + -PackageRoot 'C:\validation\agents\windows' ` + -ScheduledTaskName 'MinerOpsAgent' ` + -CheckpointAction { param(`$name) if (`$name -eq '$checkpoint') { Stop-Process -Id `$PID -Force } } +"@ +$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($code)) +Start-Process powershell.exe ` + -ArgumentList '-NoProfile','-EncodedCommand',$encoded -Wait +``` + +After termination, rerun normally without deleting, repairing, or editing any +artifact. Every retry request must use the same token and +`agent_secret_hash`; no retry may generate a replacement secret. + +## Hard-termination matrix + +| Checkpoint | State before rerun | Required rerun result | +| --- | --- | --- | +| `directory-repair-blocker-written` | external protected blocker durable before repair | no artifact is trusted; every ordinary invocation remains blocked | +| `directory-trust-established` | hardened directory; external blocker retained | pre-repair recovery/pending/remnants are quarantined without parsing; even a clean repair cannot continue in this process | +| `directory-containment-scan-complete` | initial known-name scan finished; external blocker retained | a late recovery/pending file never becomes authority on this or later ordinary invocations | +| `trust-reset-authorized` | changed full-boot epoch and strict original task intent are durable; blocker retained | approved retry preserves that intent and repeats publication; it cannot delete early | +| `trust-reset-runtime-published` | fresh package runtime is durable; blocker is `runtime-published` | approved retry resumes credentials/config/task convergence with the blocker retained | +| `quarantine-blocker-written` | blocker durable; insecure authoritative artifact may remain | second run never parses or redeems it | +| `artifact-moved-to-quarantine` | authority name absent; evidence is beneath quarantine | evidence remains non-authoritative and the quarantine directory is reverified | +| `quarantine-directory-verified` | evidence is under the protected non-traversable quarantine directory | rerun remains blocked and never reads or ACL-repairs evidence contents | +| `recovery-written` | protected recovery; original task state retained | exact saved token/secret is reused | +| `task-stopped` | recovery protected; task stopped; no request yet | transition completes and restores original task intent | +| `server-committed` | server may have committed; task stopped | idempotent same-hash redemption completes | +| `pending-written` | protected matching recovery and pending | pending is validated and reused, not overwritten | +| `before-replace` | active legacy config plus pending | first rename may proceed; rerun converges | +| `active-moved-to-prior` | active missing; matching pending and legacy prior present | prior is never promoted; pending becomes active | +| `pending-moved-to-active` | committed active plus prior; pending absent | active is verified; prior is cleanup-only | +| `after-replace` | same as prior row | active is verified before task restoration | +| `task-restored` | active and final task state verified | retry verifies again and cleans up | +| `before-trust-blocker-removal` | active config and final strict task state verified; external blocker still present | only an explicitly approved parent retry may verify again and remove the blocker | +| `before-recovery-cleanup` | active/task verified; recovery remains last | retry uses recovery and completes cleanup | + +For each row inspect ACLs and task state without displaying secret contents: + +```powershell +Get-Acl C:\MinerOpsAgent | Format-List +Get-Acl C:\MinerOpsAgent\agent-credential-transition.recovery.json ` + -ErrorAction SilentlyContinue | Format-List +Get-Acl C:\MinerOpsAgent\agent-config.json.pending ` + -ErrorAction SilentlyContinue | Format-List +Get-ScheduledTask -TaskName MinerOpsAgent | Select-Object TaskName, State +``` + +## Provenance and quarantine cases + +Run these as separate restored-snapshot cases: + +1. **Malicious recovery, three invocations.** Stop after `recovery-written`, add + a Users read/write ACE to recovery, replace its secret and + `original_task_running`, and run twice. The first run must create a blocker, + quarantine without parsing, and send no request. The second must remain + blocked, send no request, keep the real original task state, and omit the + attacker secret from all output. A third ordinary run must remain blocked. +2. **ACL-repair laundering attempt.** Terminate at + `quarantine-blocker-written`, then repair the still-authoritative recovery + file ACL by hand without deleting the blocker. The next run must quarantine + it without reading or further ACL mutation and must not redeem it. This + proves ACL correctness cannot override the durable provenance decision. +3. **Malicious pending, three invocations.** Stop after `pending-written`, add a + Users ACE and alter the credential/Agent data. All runs must send no new + request and must never promote pending. +4. **Permissive directory with attacker state.** Add Users Modify to the target + directory, place recovery and pending before repair, and run. Both artifacts + must be quarantined, a blocker retained, and no request sent. A hard stop at + `directory-trust-established` must have the same second-run result. +5. **Quarantine preservation.** A later successful, separately authorized + transition must not silently delete unresolved blocker or evidence files. + Operator review must remove them only after invalidating the exposed token + and obtaining a new package. +6. **Late arrival after scan.** At `directory-containment-scan-complete`, use a + handle opened before repair to attempt creation/rename of recovery and + pending names, including a file whose ACL now looks correct. The current, + second, and third ordinary runs must send no request and perform no + activation regardless of the handle operation's Windows result. +7. **Trust-epoch reset.** Use Restart, confirm the canonical full-kernel-boot + event identity changed, clear suspicious transition state only under the + approved procedure, and run with + `-ApproveFilesystemTrustResetAfterReboot`. Confirm fresh runtime publication + occurs while the external blocker exists, old runtime is never a rollback + source, the task remains stopped until transition verification, and the + blocker remains through credential/config convergence and final strict task + verification. It may disappear only after all of those parent checks pass. + The same switch before Restart must fail. Repeat after clock/timezone change, + sleep, hibernate, and Fast Startup; each must still fail as the same epoch. +8. **Direct installer reset attempts.** With a real external blocker present, + invoke `install.ps1 -ApproveFilesystemTrustResetAfterReboot` directly for a + successful publication and an injected publication failure. Repeat with the + task disabled, `Unknown`, `Queued`, wrong action, and wrong principal. The + blocker must remain in every case. Then run the complete parent workflow and + prove deletion occurs only after its final convergence checks. + +In every quarantine case, test standard-user reads against every +`minerops-quarantine-credential-transition-*.evidence` file. Access must be +denied, and neither console nor error transcripts may contain its contents. + +## Hard-link cases + +On NTFS, create a second name for each candidate with `New-Item -ItemType +HardLink`. Test attacker-created enrollment pending, recovery, and final config +paths; installed runtime destinations; and rollback sources/destinations. Every +case must fail before secret write, redemption, publication, or restore. Verify +that no attacker-readable alias contains a permanent credential and that a +failed `CreateNew` does not delete or adopt the pre-existing file. + +## Strict Scheduled Task matrix + +Use the exact installer-controlled action and principal as the valid fixture. +Run both durable intents through `Running`, `Ready`, missing, provider/query +exception, access denied where practical, null/empty state, `Disabled`, +`Unknown`, `Queued`, malformed object/definition, multiple results, wrong +executable/script arguments, and wrong principal. Only running plus `Running` +and stopped plus `Ready` succeed. Stopped plus `Running` must issue stop and +then positively verify `Ready`. + +Capture the actual provider types for `TaskPath`, `State`, `Settings.Enabled`, +`Actions`, action `Execute`/`Arguments`, and principal `UserId`, `LogonType`, and +`RunLevel`. Verify root `TaskPath` is an actual string equal to `\` and Enabled +is an actual Boolean `$true`. Missing, null, empty, array, numeric, and truthy +string substitutions must fail; provider enum normalization is allowed only for +`State`, `LogonType`, and `RunLevel`. + +After the server-commit checkpoint, inject final task verification failure. +Recovery must remain and no success output or cleanup may occur. Repair the task +and rerun; the recorded token and `agent_secret_hash` must be identical and the +retry must converge without generating a new secret. + +## Active-missing and replacement cases + +Run all of these with a cryptographically matching protected recovery record: + +- active missing, protected matching pending present, no prior (legacy + `ReplaceFile` partial state): converges with the same token and secret; +- active missing, matching pending and valid legacy prior present (new first + rename completed): converges through pending; prior is never authority; +- committed active and valid prior present, pending absent (new second rename + completed): verifies active and cleans prior last; +- failure before the first rename: active and pending remain, rerun converges; +- failure between the two renames: active is absent, pending and prior remain, + rerun converges; +- failure immediately after pending becomes active: active and prior remain, + rerun verifies active before cleanup; +- active missing with no recovery or with unknown pending: fails closed; +- active missing with malformed pending: fails before redemption; +- active missing with mismatched Agent, secret, or secret hash: fails before + redemption; and +- active missing with only a stale prior: fails closed and never promotes it. + +For every successful active-missing case, confirm exact Agent/Hub/Site/Zone +identity, the same token and secret hash on every request, active-file DACL, +and final Scheduled Task intent. Initially running must end running; +intentionally stopped must remain stopped. + +## Completion and cleanup + +At successful termination, recovery, pending, prior, and consumed enrollment +files are absent; unresolved quarantine/blocker evidence is never silently +removed. Console/error transcripts contain neither token nor generated secret. + +Record any disposable account, Scheduled Task, junction, directory, or mock +service left by an interrupted validation, remove only those validation +resources, then restore the snapshot or destroy the VM. diff --git a/backend/app/agent_enrollment/router.py b/backend/app/agent_enrollment/router.py index b1337d5..09b16e6 100644 --- a/backend/app/agent_enrollment/router.py +++ b/backend/app/agent_enrollment/router.py @@ -148,6 +148,7 @@ def download_windows_agent_package( package = build_windows_agent_package( enrollment_token=package_request.token, hub_url=hub_url, + workflow="new_agent", ) except AgentWindowsPackageSourceError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc @@ -166,6 +167,50 @@ def download_windows_agent_package( }, ) + +@router.post("/api/agent-credential-transitions/windows-package") +def download_windows_agent_credential_transition_package( + package_request: AgentWindowsPackageRequest, + http_request: Request, + current_user: EnrollmentManager, +) -> Response: + del current_user + + browser_origin = http_request.headers.get("origin", "").strip() + try: + hub_url = normalize_hub_url(browser_origin) + except AgentWindowsPackageError as exc: + raise HTTPException( + status_code=400, + detail=( + "Windows Agent package downloads require " + "a valid browser Origin" + ), + ) from exc + + try: + package = build_windows_agent_package( + enrollment_token=package_request.token, + hub_url=hub_url, + workflow="existing_agent_credential_transition", + ) + except AgentWindowsPackageSourceError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except AgentWindowsPackageError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return Response( + content=package, + media_type="application/zip", + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + "Content-Disposition": ( + 'attachment; filename="MinerOpsAgent-Credential-Upgrade-Windows.zip"' + ), + }, + ) + @router.post( "/api/agent/enroll", response_model=AgentEnrollResponse, diff --git a/backend/app/agent_packages/windows.py b/backend/app/agent_packages/windows.py index 9d65b10..25c5e49 100644 --- a/backend/app/agent_packages/windows.py +++ b/backend/app/agent_packages/windows.py @@ -14,6 +14,7 @@ WINDOWS_PACKAGE_ROOT = "MinerOpsAgent-Windows" WINDOWS_PACKAGE_FILES = ( "Enroll-MinerOpsAgent.ps1", + "Upgrade-MinerOpsAgentCredential.ps1", "install.ps1", "uninstall.ps1", "MinerOpsAgentService.ps1", @@ -80,6 +81,7 @@ def build_windows_agent_package( *, enrollment_token: str, hub_url: str, + workflow: str = "new_agent", source_root: Path | None = None, ) -> bytes: token = enrollment_token.strip() @@ -91,6 +93,12 @@ def build_windows_agent_package( normalized_hub_url = normalize_hub_url(hub_url) + if workflow not in { + "new_agent", + "existing_agent_credential_transition", + }: + raise AgentWindowsPackageError("Unsupported Windows Agent workflow") + root = ( Path(source_root) if source_root is not None @@ -117,18 +125,27 @@ def build_windows_agent_package( enrollment = { "hub_url": normalized_hub_url, "token": token, + "workflow": workflow, } - instructions = """MinerOps Windows Agent + if workflow == "new_agent": + command = ".\\Enroll-MinerOpsAgent.ps1" + description = "enrollment" + else: + command = ".\\Upgrade-MinerOpsAgentCredential.ps1" + description = "existing-Agent credential transition" + + instructions = f"""MinerOps Windows Agent 1. Extract this ZIP on the target Windows machine. 2. Open Windows PowerShell as Administrator. 3. Change to the extracted MinerOpsAgent-Windows folder. 4. Run: - .\\Enroll-MinerOpsAgent.ps1 + {command} -The enrollment credential is short-lived and single-use. +This package is for the {description} workflow. The credential is short-lived +and single-use. Do not use the other bootstrap script with this package. Do not post enrollment.json in chat, tickets, screenshots, or logs. Delete the ZIP and extracted setup folder after successful installation. """ diff --git a/backend/app/frontend/agent_management_view.py b/backend/app/frontend/agent_management_view.py index 35a99f0..ac212b9 100644 --- a/backend/app/frontend/agent_management_view.py +++ b/backend/app/frontend/agent_management_view.py @@ -74,7 +74,7 @@ def agent_management_content(can_manage_agents: bool) -> str: -

+

This token is displayed only for this enrollment step. Keep it private. MinerOps does not store the plaintext token.

@@ -152,7 +152,9 @@ def agent_management_content(can_manage_agents: bool) -> str: -