diff --git a/src/functions/public/Get-Context.ps1 b/src/functions/public/Get-Context.ps1 index 3d044f0..9bc6d94 100644 --- a/src/functions/public/Get-Context.ps1 +++ b/src/functions/public/Get-Context.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.2' } +#Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.5' } function Get-Context { <# diff --git a/src/functions/public/Set-Context.ps1 b/src/functions/public/Set-Context.ps1 index bc3d7d6..7914c7d 100644 --- a/src/functions/public/Set-Context.ps1 +++ b/src/functions/public/Set-Context.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.2' } +#Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.5' } function Set-Context { <# diff --git a/tests/Context.Benchmark.ps1 b/tests/Context.Benchmark.ps1 new file mode 100644 index 0000000..7d9ab93 --- /dev/null +++ b/tests/Context.Benchmark.ps1 @@ -0,0 +1,111 @@ +#Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.5' } + +<# + .SYNOPSIS + Performance benchmark for the Context module's core crypto operations. + + .DESCRIPTION + Measures the time (in microseconds) per iteration for the three operations that every + Set-Context / Get-Context call executes: key-pair derivation, seal (encrypt), and + open (decrypt). Each scenario is repeated $Iterations times and the median is reported. + + Run from the repo root after importing the module: + + Import-Module ./src -Force + pwsh -NoProfile -File tests/Context.Benchmark.ps1 + + .OUTPUTS + PSCustomObject - one row per scenario with Scenario, Iterations, Median_µs, Min_µs, Max_µs. +#> +[CmdletBinding()] +param( + # Number of iterations per scenario. + [Parameter()] + [int] $Iterations = 1000, + + # Vault name used during the benchmark (cleaned up automatically). + [Parameter()] + [string] $BenchmarkVault = 'Benchmark-Perf-Vault' +) + +Set-StrictMode -Version Latest + +function Measure-MedianMicroseconds { + param( + [scriptblock] $ScriptBlock, + [int] $Iterations + ) + + $samples = [System.Collections.Generic.List[double]]::new($Iterations) + for ($i = 0; $i -lt $Iterations; $i++) { + $sw = [System.Diagnostics.Stopwatch]::StartNew() + & $ScriptBlock | Out-Null + $sw.Stop() + $samples.Add($sw.Elapsed.TotalMilliseconds * 1000) + } + $sorted = $samples | Sort-Object + $mid = [int]($Iterations / 2) + return [pscustomobject]@{ + Median_µs = [Math]::Round($sorted[$mid], 1) + Min_µs = [Math]::Round(($sorted | Select-Object -First 1), 1) + Max_µs = [Math]::Round(($sorted | Select-Object -Last 1), 1) + } +} + +# Ensure bench vault exists and is clean +try { + Remove-ContextVault -Name $BenchmarkVault -Confirm:$false -ErrorAction SilentlyContinue +} catch {} +Set-ContextVault -Name $BenchmarkVault | Out-Null + +$results = [System.Collections.Generic.List[pscustomobject]]::new() + +Write-Host "Running Context benchmark ($Iterations iterations each)..." -ForegroundColor Cyan + +# --- Key-pair derivation (New-SodiumKeyPair with seed) --- +$seed = 'BenchmarkSeedValue' +$kpStats = Measure-MedianMicroseconds -Iterations $Iterations -ScriptBlock { + New-SodiumKeyPair -Seed $seed +} +$results.Add([pscustomobject]@{ + Scenario = 'New-SodiumKeyPair -Seed' + Iterations = $Iterations + Median_µs = $kpStats.Median_µs + Min_µs = $kpStats.Min_µs + Max_µs = $kpStats.Max_µs +}) + +# --- Seal (encrypt via Set-Context) --- +$sealStats = Measure-MedianMicroseconds -Iterations $Iterations -ScriptBlock { + Set-Context -ID 'bench-ctx' -Context @{ Value = 'benchmark' } -Vault $BenchmarkVault +} +$results.Add([pscustomobject]@{ + Scenario = 'Set-Context (seal)' + Iterations = $Iterations + Median_µs = $sealStats.Median_µs + Min_µs = $sealStats.Min_µs + Max_µs = $sealStats.Max_µs +}) + +# --- Open (decrypt via Get-Context) --- +Set-Context -ID 'bench-ctx' -Context @{ Value = 'benchmark' } -Vault $BenchmarkVault +$openStats = Measure-MedianMicroseconds -Iterations $Iterations -ScriptBlock { + Get-Context -ID 'bench-ctx' -Vault $BenchmarkVault +} +$results.Add([pscustomobject]@{ + Scenario = 'Get-Context (open/decrypt)' + Iterations = $Iterations + Median_µs = $openStats.Median_µs + Min_µs = $openStats.Min_µs + Max_µs = $openStats.Max_µs +}) + +# Cleanup +try { + Remove-ContextVault -Name $BenchmarkVault -Confirm:$false -ErrorAction SilentlyContinue +} catch {} + +Write-Host '' +Write-Host '=== Context Benchmark Results ===' -ForegroundColor Green +$results | Format-Table -AutoSize +$results diff --git a/tests/Context.CrossVersionCompat.ps1 b/tests/Context.CrossVersionCompat.ps1 new file mode 100644 index 0000000..2d68346 --- /dev/null +++ b/tests/Context.CrossVersionCompat.ps1 @@ -0,0 +1,229 @@ +<# + .SYNOPSIS + Cross-version compatibility test: Sodium 2.2.2 written data readable by Sodium 2.2.5. + + .DESCRIPTION + Two-part test: + + PART 1 — Crypto stability + Verifies that New-SodiumKeyPair -Seed produces identical keys in both Sodium versions, + and that a sealed box produced by 2.2.2 is decryptable by 2.2.5 (and vice versa). + + PART 2 — Full stack (Context on-disk format) + Uses Context 8.1.3 (Sodium 2.2.2) to write a vault + contexts to disk, then uses + Sodium 2.2.5 directly to re-derive the same keys, read the raw JSON files, and + decrypt the stored sealed boxes — confirming the on-disk contract is preserved. + + Each phase runs in a child pwsh process to ensure clean module state. +#> +[CmdletBinding()] +param( + [string] $VaultName = 'XVersionCompat-Test' +) + +$ErrorActionPreference = 'Stop' +$tempDir = Join-Path $env:TEMP "sodium-compat-$(Get-Random)" +New-Item -ItemType Directory -Path $tempDir | Out-Null + +$cryptoResultFile = Join-Path $tempDir 'crypto.json' +$writeResultFile = Join-Path $tempDir 'write.json' +$readResultFile = Join-Path $tempDir 'read.json' + +$pass = 0; $fail = 0 + +function Assert-Equal { + param($Name, $Expected, $Actual) + if ("$Actual" -eq "$Expected") { + Write-Host " [PASS] $Name" -ForegroundColor Green + $script:pass++ + } else { + Write-Host " [FAIL] $Name" -ForegroundColor Red + Write-Host " expected : $Expected" -ForegroundColor Red + Write-Host " actual : $Actual" -ForegroundColor Red + $script:fail++ + } +} +function Assert-Null { + param($Name, $Actual) + if ($null -eq $Actual -or "$Actual" -eq '') { + Write-Host " [PASS] $Name (null/empty as expected)" -ForegroundColor Green + $script:pass++ + } else { + Write-Host " [FAIL] $Name — expected null/empty, got: [$Actual]" -ForegroundColor Red + $script:fail++ + } +} + +Write-Host '' +Write-Host '=== Cross-Version Sodium Compatibility Test ===' -ForegroundColor Cyan +Write-Host "Temp dir : $tempDir" +Write-Host '' + +# --------------------------------------------------------------------------- +# PART 1 — CRYPTO STABILITY +# Confirms New-SodiumKeyPair -Seed is deterministic across versions, +# and that sealed boxes produced by 2.2.2 are openable by 2.2.5. +# --------------------------------------------------------------------------- +Write-Host '--- Part 1a: Seal message + derive keys with Sodium 2.2.2 ---' -ForegroundColor Yellow + +$part1aScript = @" +`$ErrorActionPreference = 'Stop' +Import-Module Sodium -RequiredVersion 2.2.2 -Force +`$seed = 'CompatibilityTestSeedValue' +`$plaintext = 'Hello from Sodium 2.2.2' +`$kp = New-SodiumKeyPair -Seed `$seed +`$box22 = ConvertTo-SodiumSealedBox -Message `$plaintext -PublicKey `$kp.PublicKey +`$result = @{ + PublicKey = `$kp.PublicKey + PrivateKey = `$kp.PrivateKey + SealedBox22 = `$box22 + Plaintext = `$plaintext +} +`$result | ConvertTo-Json | Set-Content '$cryptoResultFile' +Write-Host "Keys derived and message sealed with Sodium 2.2.2" +"@ + +pwsh -NoProfile -Command $part1aScript +if (-not (Test-Path $cryptoResultFile)) { throw 'Part 1a failed: no result file' } +$c22 = Get-Content $cryptoResultFile -Raw | ConvertFrom-Json +Write-Host " PublicKey (2.2.2): $($c22.PublicKey)" +Write-Host " PrivateKey (2.2.2): $($c22.PrivateKey)" + +Write-Host '' +Write-Host '--- Part 1b: Verify keys + unseal 2.2.2 message with Sodium 2.2.5 ---' -ForegroundColor Yellow + +$part1bScript = @" +`$ErrorActionPreference = 'Stop' +Import-Module Sodium -RequiredVersion 2.2.5 -Force +`$seed = 'CompatibilityTestSeedValue' +`$kp25 = New-SodiumKeyPair -Seed `$seed +`$box22 = '$($c22.SealedBox22)' +`$pubKey = '$($c22.PublicKey)' +`$privKey = '$($c22.PrivateKey)' + +# Decrypt the 2.2.2 ciphertext with 2.2.5 +`$decrypted = ConvertFrom-SodiumSealedBox -SealedBox `$box22 -PublicKey `$pubKey -PrivateKey `$privKey + +# Also seal a new message with 2.2.5 +`$box25 = ConvertTo-SodiumSealedBox -Message 'Hello from Sodium 2.2.5' -PublicKey `$kp25.PublicKey +# Decrypt the 2.2.5 message immediately (roundtrip check) +`$rt25 = ConvertFrom-SodiumSealedBox -SealedBox `$box25 -PublicKey `$kp25.PublicKey -PrivateKey `$kp25.PrivateKey + +`$result = @{ + PublicKey25 = `$kp25.PublicKey + PrivateKey25 = `$kp25.PrivateKey + Decrypted22msg = `$decrypted + Roundtrip25msg = `$rt25 +} +`$result | ConvertTo-Json | Set-Content ('$cryptoResultFile' -replace '\.json','-25.json') +Write-Host "Keys derived and 2.2.2 box decrypted with Sodium 2.2.5" +"@ + +$cryptoResult25File = $cryptoResultFile -replace '\.json', '-25.json' +pwsh -NoProfile -Command $part1bScript +if (-not (Test-Path $cryptoResult25File)) { throw 'Part 1b failed: no result file' } +$c25 = Get-Content $cryptoResult25File -Raw | ConvertFrom-Json + +Write-Host " PublicKey (2.2.5): $($c25.PublicKey25)" +Write-Host " PrivateKey (2.2.5): $($c25.PrivateKey25)" +Write-Host " Decrypted 2.2.2 msg: $($c25.Decrypted22msg)" +Write-Host " 2.2.5 roundtrip msg: $($c25.Roundtrip25msg)" +Write-Host '' + +Write-Host '--- Part 1 Assertions ---' -ForegroundColor Yellow +Assert-Equal 'Key derivation: PublicKey identical' $c22.PublicKey $c25.PublicKey25 +Assert-Equal 'Key derivation: PrivateKey identical' $c22.PrivateKey $c25.PrivateKey25 +Assert-Equal '2.2.2 sealed box decryptable by 2.2.5' $c22.Plaintext $c25.Decrypted22msg +Assert-Equal '2.2.5 roundtrip works' 'Hello from Sodium 2.2.5' $c25.Roundtrip25msg + +# --------------------------------------------------------------------------- +# PART 2 — FULL STACK: Context 8.1.3 writes, Sodium 2.2.5 reads raw files +# --------------------------------------------------------------------------- +Write-Host '' +Write-Host '--- Part 2a: Write vault + contexts with Context 8.1.3 (Sodium 2.2.2) ---' -ForegroundColor Yellow + +$part2aScript = @" +`$ErrorActionPreference = 'Stop' +Import-Module Sodium -RequiredVersion 2.2.2 -Force +Import-Module Context -Force + +Get-ContextVault -Name '$VaultName' -ErrorAction SilentlyContinue | Remove-ContextVault -Confirm:`$false -ErrorAction SilentlyContinue +Set-ContextVault -Name '$VaultName' | Out-Null + +Set-Context -ID 'compat-simple' -Context @{ Greeting = 'Hello'; Number = 42 } -Vault '$VaultName' +Set-Context -ID 'compat-secure' -Context @{ Token = ('secret123' | ConvertTo-SecureString -AsPlainText -Force) } -Vault '$VaultName' +Set-Context -ID 'compat-nulls' -Context @{ Present = 'yes'; Absent = `$null } -Vault '$VaultName' + +`$vault = Get-ContextVault -Name '$VaultName' +`$shardPath = Join-Path `$vault.Path 'shard' +`$result = @{ + VaultPath = `$vault.Path + ShardPath = `$shardPath + ShardContent = (Get-Content `$shardPath -Raw).Trim() + MachineName = [System.Environment]::MachineName + UserName = [System.Environment]::UserName + ContextFiles = (Get-ChildItem `$vault.Path -Filter '*.json' | Select-Object -ExpandProperty FullName) +} +`$result | ConvertTo-Json -Depth 5 | Set-Content '$writeResultFile' +Write-Host "Vault and contexts written with Context 8.1.3 / Sodium 2.2.2" +"@ + +pwsh -NoProfile -Command $part2aScript +if (-not (Test-Path $writeResultFile)) { throw 'Part 2a failed: no result file' } +$wr = Get-Content $writeResultFile -Raw | ConvertFrom-Json + +Write-Host " VaultPath : $($wr.VaultPath)" +Write-Host " ContextFiles : $($wr.ContextFiles.Count) json files" +Write-Host '' +Write-Host '--- Part 2b: Decrypt raw vault files using Sodium 2.2.5 directly ---' -ForegroundColor Yellow + +$contextFilesJson = ($wr.ContextFiles | ConvertTo-Json -Compress) + +$part2bScript = @" +`$ErrorActionPreference = 'Stop' +Import-Module Sodium -RequiredVersion 2.2.5 -Force + +# Re-derive the same keys as Context would +`$seed = '$($wr.MachineName)' + '$($wr.UserName)' + '$($wr.ShardContent)' +`$kp = New-SodiumKeyPair -Seed `$seed + +`$contextFiles = '$($wr.ContextFiles -join "','")' -split "','" + +`$results = @{} +foreach (`$file in `$contextFiles) { + `$json = Get-Content `$file -Raw | ConvertFrom-Json + `$plain = ConvertFrom-SodiumSealedBox -SealedBox `$json.Context -PublicKey `$kp.PublicKey -PrivateKey `$kp.PrivateKey + `$results[`$json.ID] = `$plain | ConvertFrom-Json -AsHashtable +} + +`$results | ConvertTo-Json -Depth 10 | Set-Content '$readResultFile' +Write-Host "Raw vault files decrypted with Sodium 2.2.5" +"@ + +pwsh -NoProfile -Command $part2bScript +if (-not (Test-Path $readResultFile)) { throw 'Part 2b failed: no result file' } +$rd = Get-Content $readResultFile -Raw | ConvertFrom-Json + +Write-Host '--- Part 2 Assertions ---' -ForegroundColor Yellow +Assert-Equal 'compat-simple: Greeting' 'Hello' $rd.'compat-simple'.Greeting +Assert-Equal 'compat-simple: Number' '42' $rd.'compat-simple'.Number +Assert-Equal 'compat-secure: Token (SecureString prefix)' '[SECURESTRING]secret123' $rd.'compat-secure'.Token +Assert-Equal 'compat-nulls: Present' 'yes' $rd.'compat-nulls'.Present +Assert-Null 'compat-nulls: Absent' $rd.'compat-nulls'.Absent + +# Cleanup +Write-Host '' +try { + pwsh -NoProfile -Command "Import-Module Sodium -RequiredVersion 2.2.2 -Force; Import-Module Context -Force; Get-ContextVault -Name '$VaultName' -ErrorAction SilentlyContinue | Remove-ContextVault -Confirm:`$false -ErrorAction SilentlyContinue" 2>&1 | Out-Null +} catch {} +Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue + +$total = $pass + $fail +Write-Host '' +if ($fail -eq 0) { + Write-Host "=== RESULT: ALL $total ASSERTIONS PASSED ===" -ForegroundColor Green + Write-Host " Sodium 2.2.2 → 2.2.5: key derivation is identical, on-disk contract is preserved." -ForegroundColor Green +} else { + Write-Host "=== RESULT: $fail/$total ASSERTIONS FAILED — BREAKING CHANGE DETECTED ===" -ForegroundColor Red +} +