diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index a8e37ad..a895338 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -27,5 +27,5 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@da180bac16b13bfbcdf08b2e4e221b5b49e5ff28 # v6.1.4 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@fb1bdb8fefd243292f779d2a856a38db6fe6daf4 # v6.1.13 secrets: inherit diff --git a/PSModule/Sodium/Sodium.cs b/PSModule/Sodium/Sodium.cs index 08ec69e..75f6d82 100644 --- a/PSModule/Sodium/Sodium.cs +++ b/PSModule/Sodium/Sodium.cs @@ -203,6 +203,11 @@ public static KeyPairBase64 GenerateKeyPairBase64(string seedText) } public static string DerivePublicKeyBase64(string privateKeyBase64) + { + return Convert.ToBase64String(DerivePublicKey(privateKeyBase64)); + } + + public static byte[] DerivePublicKey(string privateKeyBase64) { ArgumentNullException.ThrowIfNull(privateKeyBase64); var privateKey = DecodeBase64Exact(privateKeyBase64, SecretKeyBytes, "private key"); @@ -213,7 +218,7 @@ public static string DerivePublicKeyBase64(string privateKeyBase64) { throw new InvalidOperationException("Unable to derive public key from private key."); } - return Convert.ToBase64String(publicKey); + return publicKey; } finally { diff --git a/src/functions/private/Assert-SodiumNativeRuntime.ps1 b/src/functions/private/Assert-SodiumNativeRuntime.ps1 new file mode 100644 index 0000000..2ebcbd3 --- /dev/null +++ b/src/functions/private/Assert-SodiumNativeRuntime.ps1 @@ -0,0 +1,28 @@ +function Assert-SodiumNativeRuntime { + <# + .SYNOPSIS + Provides platform-specific diagnostics after Sodium native initialization fails. + + .DESCRIPTION + Checks the Windows Visual C++ runtime after a native initialization exception and throws a targeted message when the required + runtime is unavailable. + + .NOTES + This function only runs after native library loading fails, which cannot be reproduced safely after the library is loaded in the test process. + #> + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute()] + [OutputType([void])] + [CmdletBinding()] + param() + + process { + if ($IsWindows -and $script:ProcessArchitecture -in @('X64', 'X86')) { + $hasRuntime = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture $script:ProcessArchitecture + if (-not $hasRuntime) { + $message = "Sodium native initialization failed; the Visual C++ Redistributable for " + + "$($script:ProcessArchitecture) appears to be missing or below the required version." + throw $message + } + } + } +} diff --git a/src/functions/private/Initialize-Sodium.ps1 b/src/functions/private/Initialize-Sodium.ps1 index 2ad47bb..a4433e8 100644 --- a/src/functions/private/Initialize-Sodium.ps1 +++ b/src/functions/private/Initialize-Sodium.ps1 @@ -23,26 +23,19 @@ $initializationResult = [PSModule.Sodium]::sodium_init() } catch { $script:Supported = $false - if ($IsWindows) { - if ($script:ProcessArchitecture -in @('X64', 'X86')) { - $hasRuntime = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture $script:ProcessArchitecture - if (-not $hasRuntime) { - $message = "Sodium native initialization failed; the Visual C++ Redistributable for " + - "$($script:ProcessArchitecture) appears to be missing or below the required version." - throw $message - } - } - } + Assert-SodiumNativeRuntime throw } if ($initializationResult -lt 0) { throw 'Sodium initialization failed.' } - $script:SodiumPublicKeyBytes = [PSModule.Sodium]::crypto_box_publickeybytes().ToUInt32() - $script:SodiumPrivateKeyBytes = [PSModule.Sodium]::crypto_box_secretkeybytes().ToUInt32() - $script:SodiumSealBytes = [PSModule.Sodium]::crypto_box_sealbytes().ToUInt32() - $script:SodiumSeedBytes = [PSModule.Sodium]::crypto_box_seedbytes().ToUInt32() + # Fixed crypto_box constants (curve25519xsalsa20poly1305). The C# layer queries and validates the real + # native values at type initialization; hardcoding here avoids four extra interop call sites at import. + $script:SodiumPublicKeyBytes = 32u + $script:SodiumPrivateKeyBytes = 32u + $script:SodiumSealBytes = 48u + $script:SodiumSeedBytes = 32u $script:SodiumInitialized = $true } diff --git a/src/functions/private/Resolve-SodiumRuntimeIdentifier.ps1 b/src/functions/private/Resolve-SodiumRuntimeIdentifier.ps1 new file mode 100644 index 0000000..50b9dfb --- /dev/null +++ b/src/functions/private/Resolve-SodiumRuntimeIdentifier.ps1 @@ -0,0 +1,65 @@ +function Resolve-SodiumRuntimeIdentifier { + <# + .SYNOPSIS + Resolves the native Sodium runtime identifier. + + .DESCRIPTION + Maps the active operating system and process architecture to the runtime identifier used by the bundled native library. + #> + [OutputType([string])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Runtime.InteropServices.Architecture] $ProcessArchitecture, + + [Parameter()] + [switch] $Linux, + + [Parameter()] + [switch] $MacOS, + + [Parameter()] + [switch] $Windows + ) + + process { + switch ($true) { + $Linux { + switch ($ProcessArchitecture) { + 'Arm64' { return 'linux-arm64' } + 'X64' { return 'linux-x64' } + default { + $message = "Unsupported Linux process architecture: $ProcessArchitecture. " + + 'Please refer to the documentation for supported architectures.' + throw $message + } + } + } + $MacOS { + switch ($ProcessArchitecture) { + 'Arm64' { return 'osx-arm64' } + 'X64' { return 'osx-x64' } + default { + $message = "Unsupported macOS process architecture: $ProcessArchitecture. " + + 'Please refer to the documentation for supported architectures.' + throw $message + } + } + } + $Windows { + switch ($ProcessArchitecture) { + 'X64' { return 'win-x64' } + 'X86' { return 'win-x86' } + default { + $message = "Unsupported Windows process architecture: $ProcessArchitecture. " + + 'Please refer to the documentation for supported architectures.' + throw $message + } + } + } + default { + throw 'Unsupported platform. Please refer to the documentation for more information.' + } + } + } +} diff --git a/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 b/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 index 5b72e4a..540b0d1 100644 --- a/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 +++ b/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 @@ -68,10 +68,6 @@ [string] $PrivateKey ) - begin { - Initialize-Sodium - } - process { try { if (-not [string]::IsNullOrWhiteSpace($PublicKey)) { diff --git a/src/functions/public/ConvertTo-SodiumSealedBox.ps1 b/src/functions/public/ConvertTo-SodiumSealedBox.ps1 index ff87d42..43dd858 100644 --- a/src/functions/public/ConvertTo-SodiumSealedBox.ps1 +++ b/src/functions/public/ConvertTo-SodiumSealedBox.ps1 @@ -55,10 +55,6 @@ [ValidateNotNullOrEmpty()] [string] $PublicKey ) - begin { - Initialize-Sodium - } - process { try { return [PSModule.Sodium]::SealBase64($Message, $PublicKey) diff --git a/src/functions/public/Get-SodiumPublicKey.ps1 b/src/functions/public/Get-SodiumPublicKey.ps1 index 8a7ab08..77ac1d2 100644 --- a/src/functions/public/Get-SodiumPublicKey.ps1 +++ b/src/functions/public/Get-SodiumPublicKey.ps1 @@ -67,6 +67,10 @@ https://psmodule.io/Sodium/Functions/Get-SodiumPublicKey/ #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseOutputTypeCorrectly', '', + Justification = 'The unary comma preserves the byte array as one pipeline object.' + )] [OutputType([string], ParameterSetName = 'Base64')] [OutputType([byte[]], ParameterSetName = 'AsByteArray')] [CmdletBinding(DefaultParameterSetName = 'Base64')] @@ -81,14 +85,10 @@ [switch] $AsByteArray ) - begin { - Initialize-Sodium - } - process { if ($AsByteArray) { try { - return [System.Convert]::FromBase64String([PSModule.Sodium]::DerivePublicKeyBase64($PrivateKey)) + return , ([PSModule.Sodium]::DerivePublicKey($PrivateKey)) } catch [System.Management.Automation.MethodInvocationException] { throw $_.Exception.InnerException } @@ -100,5 +100,4 @@ } } - end {} } diff --git a/src/functions/public/New-SodiumKeyPair.ps1 b/src/functions/public/New-SodiumKeyPair.ps1 index a8bebe5..af515a2 100644 --- a/src/functions/public/New-SodiumKeyPair.ps1 +++ b/src/functions/public/New-SodiumKeyPair.ps1 @@ -84,10 +84,6 @@ [string] $Seed ) - begin { - Initialize-Sodium - } - process { try { if ($PSCmdlet.ParameterSetName -eq 'SeededKeyPair') { diff --git a/src/main.ps1 b/src/main.ps1 index 0d1d6f2..7cff0d1 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -1,39 +1,9 @@ $processArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -switch ($true) { - $IsLinux { - switch ($processArchitecture) { - 'Arm64' { $runtimeIdentifier = 'linux-arm64' } - 'X64' { $runtimeIdentifier = 'linux-x64' } - default { - throw "Unsupported Linux process architecture: $processArchitecture. Please refer to the documentation for supported architectures." - } - } - } - $IsMacOS { - switch ($processArchitecture) { - 'Arm64' { $runtimeIdentifier = 'osx-arm64' } - 'X64' { $runtimeIdentifier = 'osx-x64' } - default { - throw "Unsupported macOS process architecture: $processArchitecture. Please refer to the documentation for supported architectures." - } - } - } - $IsWindows { - switch ($processArchitecture) { - 'X64' { $runtimeIdentifier = 'win-x64' } - 'X86' { $runtimeIdentifier = 'win-x86' } - default { - throw "Unsupported Windows process architecture: $processArchitecture. Please refer to the documentation for supported architectures." - } - } - } - default { - throw 'Unsupported platform. Please refer to the documentation for more information.' - } -} +$runtimeIdentifier = Resolve-SodiumRuntimeIdentifier -ProcessArchitecture $processArchitecture ` + -Linux:$IsLinux -MacOS:$IsMacOS -Windows:$IsWindows -$assemblyPath = Join-Path -Path $PSScriptRoot -ChildPath "libs/$runtimeIdentifier/PSModule.Sodium.dll" +$assemblyPath = [System.IO.Path]::Combine($PSScriptRoot, 'libs', $runtimeIdentifier, 'PSModule.Sodium.dll') Import-Module $assemblyPath -ErrorAction Stop # Optimistically mark supported; Initialize-Sodium runs during module import and checks Windows VC++ runtime only if native init fails. diff --git a/tests/Sodium.Tests.ps1 b/tests/Sodium.Tests.ps1 index 95680de..404c4b8 100644 --- a/tests/Sodium.Tests.ps1 +++ b/tests/Sodium.Tests.ps1 @@ -163,6 +163,14 @@ Describe 'Sodium' { $derivedPublicKey | Should -Be $expectedPublicKey } + It 'Get-SodiumPublicKey - Returns the public key as a byte array' { + $keyPair = New-SodiumKeyPair + $derivedPublicKey = Get-SodiumPublicKey -PrivateKey $keyPair.PrivateKey -AsByteArray + + ($derivedPublicKey -is [byte[]]) | Should -BeTrue + [Convert]::ToBase64String($derivedPublicKey) | Should -Be $keyPair.PublicKey + } + It 'Get-SodiumPublicKey - Throws an error when an invalid private key is provided' { $invalidPrivateKey = 'InvalidKey' @@ -185,5 +193,140 @@ Describe 'Sodium' { $result | Should -BeTrue } } + + It 'Assert-VisualCRedistributableInstalled reports a missing runtime' { + InModuleScope Sodium { + Mock Get-ItemProperty { $null } + + Set-Variable -Name IsWindows -Value $true -Scope Script + try { + $result = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture 'X64' 3>$null + $result | Should -BeFalse + } finally { + Remove-Variable -Name IsWindows -Scope Script + } + } + } + + It 'Assert-VisualCRedistributableInstalled accepts a matching runtime' { + InModuleScope Sodium { + Mock Get-ItemProperty { + [pscustomobject]@{ + Installed = 1 + Version = 'v14.30.30704.0' + } + } + + Set-Variable -Name IsWindows -Value $true -Scope Script + try { + $result = Assert-VisualCRedistributableInstalled -Version '14.0' + $result | Should -BeTrue + } finally { + Remove-Variable -Name IsWindows -Scope Script + } + } + } + + It 'Initialize-Sodium treats repeated initialization as a no-op' { + InModuleScope Sodium { + $script:SodiumInitialized | Should -BeTrue + { Initialize-Sodium } | Should -Not -Throw + } + } + + It 'Initialize-Sodium restores cached native buffer sizes' { + InModuleScope Sodium { + $script:SodiumInitialized = $false + $script:SodiumPublicKeyBytes = $null + $script:SodiumPrivateKeyBytes = $null + $script:SodiumSealBytes = $null + $script:SodiumSeedBytes = $null + + Initialize-Sodium + + $script:SodiumInitialized | Should -BeTrue + $script:SodiumPublicKeyBytes | Should -Be 32 + $script:SodiumPrivateKeyBytes | Should -Be 32 + $script:SodiumSealBytes | Should -Be 48 + $script:SodiumSeedBytes | Should -Be 32 + } + } + + It 'Initialize-Sodium rejects an unsupported platform' { + InModuleScope Sodium { + $script:Supported = $false + try { + { Initialize-Sodium } | Should -Throw 'Sodium is not supported on this platform.' + } finally { + $script:Supported = $true + } + } + } + + It 'Resolve-SodiumRuntimeIdentifier maps every supported runtime' { + InModuleScope Sodium { + Resolve-SodiumRuntimeIdentifier -ProcessArchitecture 'Arm64' -Linux | Should -Be 'linux-arm64' + Resolve-SodiumRuntimeIdentifier -ProcessArchitecture 'X64' -Linux | Should -Be 'linux-x64' + Resolve-SodiumRuntimeIdentifier -ProcessArchitecture 'Arm64' -MacOS | Should -Be 'osx-arm64' + Resolve-SodiumRuntimeIdentifier -ProcessArchitecture 'X64' -MacOS | Should -Be 'osx-x64' + Resolve-SodiumRuntimeIdentifier -ProcessArchitecture 'X64' -Windows | Should -Be 'win-x64' + Resolve-SodiumRuntimeIdentifier -ProcessArchitecture 'X86' -Windows | Should -Be 'win-x86' + } + } + + It 'Resolve-SodiumRuntimeIdentifier rejects unsupported runtimes' { + InModuleScope Sodium { + { Resolve-SodiumRuntimeIdentifier -ProcessArchitecture 'Arm' -Linux } | + Should -Throw 'Unsupported Linux process architecture: Arm.*' + { Resolve-SodiumRuntimeIdentifier -ProcessArchitecture 'Arm' -MacOS } | + Should -Throw 'Unsupported macOS process architecture: Arm.*' + { Resolve-SodiumRuntimeIdentifier -ProcessArchitecture 'Arm' -Windows } | + Should -Throw 'Unsupported Windows process architecture: Arm.*' + { Resolve-SodiumRuntimeIdentifier -ProcessArchitecture 'X64' } | + Should -Throw 'Unsupported platform.*' + } + } + } + + Context 'Parallel sessions' { + It 'Loads and completes crypto round trips in parallel runspaces' { + $modulePath = (Get-Module -Name Sodium -ErrorAction Stop).Path + Test-Path -Path $modulePath | Should -BeTrue + $results = 1..4 | ForEach-Object -Parallel { + Import-Module -Name $using:modulePath -Force + $keyPair = New-SodiumKeyPair -Seed "Runspace-$_" + $message = "Parallel runspace $_" + $sealedBox = ConvertTo-SodiumSealedBox -Message $message -PublicKey $keyPair.PublicKey + ConvertFrom-SodiumSealedBox -SealedBox $sealedBox -PrivateKey $keyPair.PrivateKey + } -ThrottleLimit 4 + + $results | Should -HaveCount 4 + $results | Should -Contain 'Parallel runspace 1' + $results | Should -Contain 'Parallel runspace 4' + } + + It 'Loads and completes crypto round trips in parallel processes' { + $modulePath = (Get-Module -Name Sodium -ErrorAction Stop).Path + $jobs = 1..4 | ForEach-Object { + Start-Job -ScriptBlock { + $id = $args[0] + $modulePath = $args[1] + Import-Module -Name $modulePath -Force + $keyPair = New-SodiumKeyPair -Seed "Process-$id" + $message = "Parallel process $id" + $sealedBox = ConvertTo-SodiumSealedBox -Message $message -PublicKey $keyPair.PublicKey + ConvertFrom-SodiumSealedBox -SealedBox $sealedBox -PrivateKey $keyPair.PrivateKey + } -ArgumentList $_, $modulePath + } + + try { + $results = $jobs | Receive-Job -Wait + $results | Should -HaveCount 4 + $results | Should -Contain 'Parallel process 1' + $results | Should -Contain 'Parallel process 4' + } finally { + $jobs | Remove-Job -Force + } + } } } diff --git a/tools/benchmark/Build-LocalModule.ps1 b/tools/benchmark/Build-LocalModule.ps1 new file mode 100644 index 0000000..bdc8721 --- /dev/null +++ b/tools/benchmark/Build-LocalModule.ps1 @@ -0,0 +1,67 @@ +<# + .SYNOPSIS + Assembles an importable Sodium module from the src folder for local testing and benchmarking. + + .DESCRIPTION + Mimics the PSModule build pipeline order (variables -> private functions -> public functions -> main.ps1) + and produces a Sodium.psd1/Sodium.psm1 pair with the native libs copied alongside. + + .EXAMPLE + .\Build-LocalModule.ps1 -OutputPath $env:TEMP\SodiumBench +#> +[CmdletBinding()] +param( + # Path to the module source folder. Defaults to /src. + [Parameter()] + [string] $SourcePath = (Join-Path -Path $PSScriptRoot -ChildPath '..\..\src'), + + # Folder in which the 'Sodium' module folder is created. + [Parameter(Mandatory)] + [string] $OutputPath +) +$ErrorActionPreference = 'Stop' + +$SourcePath = (Resolve-Path $SourcePath).Path +$moduleDir = Join-Path $OutputPath 'Sodium' +if (Test-Path $moduleDir) { + try { + Remove-Item $moduleDir -Recurse -Force + } catch { + # Native libs may be locked by a process that imported a previous build; build into a unique folder instead. + $moduleDir = Join-Path $OutputPath "Sodium-$([Guid]::NewGuid().ToString('N').Substring(0, 8))" + Write-Verbose "Previous build is locked; building into $moduleDir" -Verbose + } +} +New-Item -ItemType Directory -Path $moduleDir -Force | Out-Null + +$sb = [System.Text.StringBuilder]::new() +$publicFunctions = @() + +foreach ($file in (Get-ChildItem (Join-Path -Path $SourcePath -ChildPath 'variables\private') -Filter *.ps1 -ErrorAction SilentlyContinue)) { + [void]$sb.AppendLine((Get-Content $file.FullName -Raw)) +} +foreach ($file in (Get-ChildItem (Join-Path -Path $SourcePath -ChildPath 'functions\private') -Filter *.ps1)) { + [void]$sb.AppendLine((Get-Content $file.FullName -Raw)) +} +foreach ($file in (Get-ChildItem (Join-Path -Path $SourcePath -ChildPath 'functions\public') -Filter *.ps1)) { + [void]$sb.AppendLine((Get-Content $file.FullName -Raw)) + $publicFunctions += $file.BaseName +} +$mainPath = Join-Path -Path $SourcePath -ChildPath 'main.ps1' +if (Test-Path $mainPath) { + [void]$sb.AppendLine((Get-Content $mainPath -Raw)) +} +[void]$sb.AppendLine("Export-ModuleMember -Function '$($publicFunctions -join "', '")' -Alias '*'") + +Set-Content -Path (Join-Path -Path $moduleDir -ChildPath 'Sodium.psm1') -Value $sb.ToString() -Encoding UTF8BOM + +Copy-Item -Path (Join-Path -Path $SourcePath -ChildPath 'libs') -Destination $moduleDir -Recurse + +New-ModuleManifest -Path (Join-Path -Path $moduleDir -ChildPath 'Sodium.psd1') ` + -RootModule 'Sodium.psm1' ` + -ModuleVersion '999.0.0' ` + -FunctionsToExport $publicFunctions ` + -CmdletsToExport @() -VariablesToExport @() -AliasesToExport @() + +Write-Verbose "Built module at $moduleDir" -Verbose +Join-Path -Path $moduleDir -ChildPath 'Sodium.psd1' diff --git a/tools/benchmark/Invoke-ImportBenchmark.ps1 b/tools/benchmark/Invoke-ImportBenchmark.ps1 new file mode 100644 index 0000000..d63a680 --- /dev/null +++ b/tools/benchmark/Invoke-ImportBenchmark.ps1 @@ -0,0 +1,58 @@ +<# + .SYNOPSIS + Measures cold module import time in fresh PowerShell processes. + + .DESCRIPTION + Starts a new pwsh process per sample, imports the module once and reports average/min/max import time. + This is the dominant cost when the module is started in multiple sessions. + + .EXAMPLE + .\Invoke-ImportBenchmark.ps1 -ModulePath C:\temp\SodiumBench\Sodium\Sodium.psd1 -Label baseline +#> +[CmdletBinding()] +param( + # Path to the Sodium.psd1 to benchmark. Defaults to building the module from src into a temp folder. + [Parameter()] + [string] $ModulePath, + + # Label used in output file name (import-