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 if ($Scope -eq 'CurrentUser') { & chmod 700 $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 ($Scope -eq 'CurrentUser') { & chmod 600 $keyPath } else { # LocalMachine semantics: any local user may decrypt, only root may change the key & chmod 644 $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() } } |