Private/ConfigProtection.ps1

<#
.SYNOPSIS
    Internal encryption helpers used by Protect-ConfigValue and Unprotect-ConfigValue.

.DESCRIPTION
    - Windows: DPAPI through System.Security.Cryptography.ProtectedData.
    - Linux/macOS (or when a key is supplied): AES-256-CBC with a random IV, authenticated
      with HMAC-SHA256 (encrypt-then-MAC). Encryption and MAC keys are derived from a
      32-byte master key.

    Protected values are strings of the form 'tcs:v1:<method>:<base64>'.

.NOTES
    Private helpers for the tcs.core module. Written to run on Windows PowerShell 5.1
    (.NET Framework) and PowerShell 7+, so .NET Core-only APIs are avoided.
#>


$script:ProtectedValuePrefix = 'tcs:v1'
$script:DpapiEntropy = [System.Text.Encoding]::UTF8.GetBytes('tcs.core:v1')

function Initialize-DataProtection {
    [CmdletBinding()]
    [OutputType([void])]
    param()

    if ('System.Security.Cryptography.ProtectedData' -as [type]) {
        return
    }
    foreach ($assemblyName in @('System.Security', 'System.Security.Cryptography.ProtectedData')) {
        try {
            Add-Type -AssemblyName $assemblyName -ErrorAction Stop
            if ('System.Security.Cryptography.ProtectedData' -as [type]) {
                return
            }
        }
        catch {
            Write-Verbose "Could not load assembly '$assemblyName': $($_.Exception.Message)"
        }
    }
    throw 'The DPAPI ProtectedData type could not be loaded.'
}

function Get-ProtectionKeyPath {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('CurrentUser', 'LocalMachine')]
        [string]$Scope
    )

    if ($Scope -eq 'LocalMachine') {
        if (-not [string]::IsNullOrWhiteSpace($env:TCS_MACHINE_KEY_PATH)) {
            return $env:TCS_MACHINE_KEY_PATH
        }
        return '/etc/tcs.core/protection.key'
    }
    return (Join-Path -Path (Join-Path -Path (Get-ModuleConfigRoot) -ChildPath 'tcs.core') -ChildPath 'protection.key')
}

function Get-ProtectionKey {
    <#
    .SYNOPSIS
        Loads (and when allowed, creates) the 32-byte master key for the given scope on Linux/macOS.
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('CurrentUser', 'LocalMachine')]
        [string]$Scope,

        [switch]$Create
    )

    $keyPath = Get-ProtectionKeyPath -Scope $Scope

    if (-not (Test-Path -LiteralPath $keyPath)) {
        if (-not $Create) {
            throw "No $Scope protection key found at '$keyPath'. The value was protected on a different machine or by a different user."
        }
        $key = New-Object byte[] 32
        $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
        try { $rng.GetBytes($key) } finally { $rng.Dispose() }

        try {
            $keyDirectory = Split-Path -Path $keyPath -Parent
            if (-not (Test-Path -LiteralPath $keyDirectory)) {
                $null = New-Item -Path $keyDirectory -ItemType Directory -Force -ErrorAction Stop
            }
            $setPermissions = -not (Test-IsWindowsPlatform)
            if ($setPermissions -and $Scope -eq 'CurrentUser') {
                # Also when the folder already exists (it holds the settings files too)
                & chmod 700 $keyDirectory
                if ($LASTEXITCODE -ne 0) {
                    throw "Failed to set permissions on '$keyDirectory'."
                }
            }

            # Create the empty file and restrict it before any key material is written
            $null = New-Item -Path $keyPath -ItemType File -Force -ErrorAction Stop
            if ($setPermissions) {
                # LocalMachine semantics: any local user may decrypt, only root may change the key
                $fileMode = if ($Scope -eq 'CurrentUser') { '600' } else { '644' }
                & chmod $fileMode $keyPath
                if ($LASTEXITCODE -ne 0) {
                    Remove-Item -LiteralPath $keyPath -Force -ErrorAction SilentlyContinue
                    throw "Failed to set permissions on '$keyPath'."
                }
            }
            [System.IO.File]::WriteAllText($keyPath, [Convert]::ToBase64String($key))
        }
        catch {
            # Never leave an empty or partial key file behind
            if ((Test-Path -LiteralPath $keyPath) -and (Get-Item -LiteralPath $keyPath).Length -lt 44) {
                Remove-Item -LiteralPath $keyPath -Force -ErrorAction SilentlyContinue
            }
            if ($Scope -eq 'LocalMachine') {
                throw "Cannot create the LocalMachine protection key at '$keyPath'. Run Protect-ConfigValue -Scope LocalMachine once as root to create it, or set TCS_MACHINE_KEY_PATH to a writable location. $($_.Exception.Message)"
            }
            throw
        }
        return , $key
    }

    $key = [Convert]::FromBase64String(([System.IO.File]::ReadAllText($keyPath)).Trim())
    if ($key.Length -ne 32) {
        throw "The protection key at '$keyPath' is invalid (expected 32 bytes)."
    }
    return , $key
}

function Get-DerivedKey {
    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)]
        [byte[]]$MasterKey,

        [Parameter(Mandatory)]
        [string]$Purpose
    )

    $hmac = New-Object System.Security.Cryptography.HMACSHA256 -ArgumentList (, $MasterKey)
    try {
        return , $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes("tcs.core:$Purpose"))
    }
    finally {
        $hmac.Dispose()
    }
}

function Protect-BytesWithKey {
    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)]
        [byte[]]$Data,

        [Parameter(Mandatory)]
        [byte[]]$MasterKey
    )

    $encKey = Get-DerivedKey -MasterKey $MasterKey -Purpose 'enc'
    $macKey = Get-DerivedKey -MasterKey $MasterKey -Purpose 'mac'

    $aes = [System.Security.Cryptography.Aes]::Create()
    try {
        $aes.Mode = [System.Security.Cryptography.CipherMode]::CBC
        $aes.Padding = [System.Security.Cryptography.PaddingMode]::PKCS7
        $aes.Key = $encKey
        $aes.GenerateIV()
        $encryptor = $aes.CreateEncryptor()
        try {
            $cipherText = $encryptor.TransformFinalBlock($Data, 0, $Data.Length)
        }
        finally {
            $encryptor.Dispose()
        }
        $payload = New-Object byte[] ($aes.IV.Length + $cipherText.Length)
        [Array]::Copy($aes.IV, 0, $payload, 0, $aes.IV.Length)
        [Array]::Copy($cipherText, 0, $payload, $aes.IV.Length, $cipherText.Length)
    }
    finally {
        $aes.Dispose()
    }

    $hmac = New-Object System.Security.Cryptography.HMACSHA256 -ArgumentList (, $macKey)
    try {
        $tag = $hmac.ComputeHash($payload)
    }
    finally {
        $hmac.Dispose()
    }

    $result = New-Object byte[] ($payload.Length + $tag.Length)
    [Array]::Copy($payload, 0, $result, 0, $payload.Length)
    [Array]::Copy($tag, 0, $result, $payload.Length, $tag.Length)
    return , $result
}

function Unprotect-BytesWithKey {
    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)]
        [byte[]]$Data,

        [Parameter(Mandatory)]
        [byte[]]$MasterKey
    )

    $tagLength = 32
    $ivLength = 16
    if ($Data.Length -lt ($ivLength + 16 + $tagLength)) {
        throw 'The protected value is truncated or corrupt.'
    }

    $encKey = Get-DerivedKey -MasterKey $MasterKey -Purpose 'enc'
    $macKey = Get-DerivedKey -MasterKey $MasterKey -Purpose 'mac'

    $payloadLength = $Data.Length - $tagLength
    $payload = New-Object byte[] $payloadLength
    [Array]::Copy($Data, 0, $payload, 0, $payloadLength)

    $hmac = New-Object System.Security.Cryptography.HMACSHA256 -ArgumentList (, $macKey)
    try {
        $expectedTag = $hmac.ComputeHash($payload)
    }
    finally {
        $hmac.Dispose()
    }

    # Constant-time comparison (CryptographicOperations is not available on .NET Framework)
    $difference = 0
    for ($i = 0; $i -lt $tagLength; $i++) {
        $difference = $difference -bor ($expectedTag[$i] -bxor $Data[$payloadLength + $i])
    }
    if ($difference -ne 0) {
        throw 'The protected value failed its integrity check. It was protected with a different key or has been modified.'
    }

    $iv = New-Object byte[] $ivLength
    [Array]::Copy($payload, 0, $iv, 0, $ivLength)

    $aes = [System.Security.Cryptography.Aes]::Create()
    try {
        $aes.Mode = [System.Security.Cryptography.CipherMode]::CBC
        $aes.Padding = [System.Security.Cryptography.PaddingMode]::PKCS7
        $aes.Key = $encKey
        $aes.IV = $iv
        $decryptor = $aes.CreateDecryptor()
        try {
            return , $decryptor.TransformFinalBlock($payload, $ivLength, $payloadLength - $ivLength)
        }
        finally {
            $decryptor.Dispose()
        }
    }
    finally {
        $aes.Dispose()
    }
}

# ConvertFrom-SecureString -Key output starts with this marker (tcs.core 0.2.x LocalMachine values)
$script:LegacyKeyedValueMarker = '76492d1116743f0423413b16050a5345'
# Header of a DPAPI blob in hex (tcs.core 0.2.x CurrentUser values on Windows)
$script:LegacyDpapiValueMarker = '01000000d08c9ddf0115d1118c7a00c04fc297eb'

function ConvertFrom-LegacyProtectedValue {
    <#
    .SYNOPSIS
        Decrypts a value protected by tcs.core 0.2.x (ConvertFrom-SecureString output) to a
        SecureString, and throws a clear error for anything that is not in that format.
    #>

    [CmdletBinding()]
    [OutputType([System.Security.SecureString])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Value
    )

    $isKeyed = $Value.StartsWith($script:LegacyKeyedValueMarker, [System.StringComparison]::OrdinalIgnoreCase)
    $isUserValue = $false
    if (-not $isKeyed -and $Value -match '^[0-9a-fA-F]+$') {
        if (Test-IsWindowsPlatform) {
            $isUserValue = $Value.StartsWith($script:LegacyDpapiValueMarker, [System.StringComparison]::OrdinalIgnoreCase)
        }
        else {
            # PowerShell 7 on Linux/macOS wrote the UTF-16 text as hex: 4 hex digits per character
            $isUserValue = ($Value.Length % 4) -eq 0
        }
    }
    if (-not ($isKeyed -or $isUserValue)) {
        throw "The value is not protected: it is neither a Protect-ConfigValue value ('$($script:ProtectedValuePrefix):...') nor a value protected by tcs.core 0.2.x."
    }

    Write-Warning 'This value uses the legacy tcs.core 0.2.x format. Protect it again with Protect-ConfigValue to use the stronger format.'

    if (-not $isKeyed) {
        return (ConvertTo-SecureString -String $Value -ErrorAction Stop)
    }

    # 0.2.x derived the LocalMachine key from $env:COMPUTERNAME, which is empty on Linux/macOS
    $computerNames = New-Object System.Collections.Generic.List[string]
    foreach ($name in @($env:COMPUTERNAME, [Environment]::MachineName, '')) {
        $candidate = [string]$name
        if (-not $computerNames.Contains($candidate)) {
            $computerNames.Add($candidate)
        }
    }
    $sha256 = [System.Security.Cryptography.SHA256]::Create()
    try {
        foreach ($computerName in $computerNames) {
            $legacyKey = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($computerName + 'tcs.core'))
            try {
                return (ConvertTo-SecureString -String $Value -Key $legacyKey -ErrorAction Stop)
            }
            catch {
                Write-Verbose "The legacy value could not be decrypted with the key for computer name '$computerName'."
            }
        }
    }
    finally {
        $sha256.Dispose()
    }
    throw 'The legacy LocalMachine value could not be decrypted on this machine.'
}