From cbc897815dc256c797bfe945489856445c059834 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 15 May 2026 01:51:00 +0200 Subject: [PATCH 01/22] Harden Sodium interop validation and cache runtime constants --- PSModule/Sodium/Sodium.cs | 153 +++++++++++++++--- PSModule/build.ps1 | 21 ++- ...Assert-VisualCRedistributableInstalled.ps1 | 27 +++- src/functions/private/Initialize-Sodium.ps1 | 34 ++++ .../public/ConvertFrom-SodiumSealedBox.ps1 | 68 +++++--- .../public/ConvertTo-SodiumSealedBox.ps1 | 38 ++--- src/functions/public/Get-SodiumPublicKey.ps1 | 36 +++-- src/functions/public/New-SodiumKeyPair.ps1 | 63 +++++--- src/main.ps1 | 48 +++--- src/variables/private/Initialized.ps1 | 4 + tests/Sodium.Tests.ps1 | 15 ++ 11 files changed, 375 insertions(+), 132 deletions(-) create mode 100644 src/functions/private/Initialize-Sodium.ps1 create mode 100644 src/variables/private/Initialized.ps1 diff --git a/PSModule/Sodium/Sodium.cs b/PSModule/Sodium/Sodium.cs index 44b5f33..14e7e07 100644 --- a/PSModule/Sodium/Sodium.cs +++ b/PSModule/Sodium/Sodium.cs @@ -5,32 +5,149 @@ namespace PSModule { public static class Sodium { - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] - public static extern int sodium_init(); + private static class Native + { + [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + public static extern int sodium_init(); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] - public static extern int crypto_box_keypair(byte[] publicKey, byte[] privateKey); + [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + public static extern int crypto_box_keypair(byte[] publicKey, byte[] privateKey); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] - public static extern int crypto_box_seed_keypair(byte[] publicKey, byte[] privateKey, byte[] seed); + [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + public static extern int crypto_box_seed_keypair(byte[] publicKey, byte[] privateKey, byte[] seed); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] - public static extern int crypto_box_seal(byte[] ciphertext, byte[] message, ulong mlen, byte[] publicKey); + [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + public static extern int crypto_box_seal(byte[] ciphertext, byte[] message, ulong mlen, byte[] publicKey); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] - public static extern int crypto_box_seal_open(byte[] decrypted, byte[] ciphertext, ulong clen, byte[] publicKey, byte[] privateKey); + [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + public static extern int crypto_box_seal_open(byte[] decrypted, byte[] ciphertext, ulong clen, byte[] publicKey, byte[] privateKey); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] - public static extern UIntPtr crypto_box_publickeybytes(); + [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + public static extern UIntPtr crypto_box_publickeybytes(); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] - public static extern UIntPtr crypto_box_secretkeybytes(); + [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + public static extern UIntPtr crypto_box_secretkeybytes(); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] - public static extern UIntPtr crypto_box_sealbytes(); + [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + public static extern UIntPtr crypto_box_sealbytes(); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] - public static extern int crypto_scalarmult_base(byte[] publicKey, byte[] privateKey); + [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + public static extern int crypto_scalarmult_base(byte[] publicKey, byte[] privateKey); + } + public static int sodium_init() + { + return Native.sodium_init(); + } + + public static int crypto_box_keypair(byte[] publicKey, byte[] privateKey) + { + ValidateMinimumBufferLength(publicKey, GetRequiredLength(crypto_box_publickeybytes()), nameof(publicKey)); + ValidateMinimumBufferLength(privateKey, GetRequiredLength(crypto_box_secretkeybytes()), nameof(privateKey)); + + return Native.crypto_box_keypair(publicKey, privateKey); + } + + public static int crypto_box_seed_keypair(byte[] publicKey, byte[] privateKey, byte[] seed) + { + ValidateMinimumBufferLength(publicKey, GetRequiredLength(crypto_box_publickeybytes()), nameof(publicKey)); + ValidateMinimumBufferLength(privateKey, GetRequiredLength(crypto_box_secretkeybytes()), nameof(privateKey)); + ValidateExactBufferLength(seed, GetRequiredLength(crypto_box_secretkeybytes()), nameof(seed)); + + return Native.crypto_box_seed_keypair(publicKey, privateKey, seed); + } + + public static int crypto_box_seal(byte[] ciphertext, byte[] message, ulong mlen, byte[] publicKey) + { + ValidateMinimumBufferLength(message, mlen, nameof(message)); + ValidateMinimumBufferLength(ciphertext, checked(mlen + crypto_box_sealbytes().ToUInt64()), nameof(ciphertext)); + ValidateExactBufferLength(publicKey, GetRequiredLength(crypto_box_publickeybytes()), nameof(publicKey)); + + return Native.crypto_box_seal(ciphertext, message, mlen, publicKey); + } + + public static int crypto_box_seal_open(byte[] decrypted, byte[] ciphertext, ulong clen, byte[] publicKey, byte[] privateKey) + { + var sealBytes = crypto_box_sealbytes().ToUInt64(); + if (clen < sealBytes) + { + throw new ArgumentException($"The ciphertext must be at least {sealBytes} bytes.", nameof(ciphertext)); + } + + ValidateMinimumBufferLength(ciphertext, clen, nameof(ciphertext)); + ValidateMinimumBufferLength(decrypted, clen - sealBytes, nameof(decrypted)); + ValidateExactBufferLength(publicKey, GetRequiredLength(crypto_box_publickeybytes()), nameof(publicKey)); + ValidateExactBufferLength(privateKey, GetRequiredLength(crypto_box_secretkeybytes()), nameof(privateKey)); + + return Native.crypto_box_seal_open(decrypted, ciphertext, clen, publicKey, privateKey); + } + + public static UIntPtr crypto_box_publickeybytes() + { + return Native.crypto_box_publickeybytes(); + } + + public static UIntPtr crypto_box_secretkeybytes() + { + return Native.crypto_box_secretkeybytes(); + } + + public static UIntPtr crypto_box_sealbytes() + { + return Native.crypto_box_sealbytes(); + } + + public static int crypto_scalarmult_base(byte[] publicKey, byte[] privateKey) + { + ValidateMinimumBufferLength(publicKey, GetRequiredLength(crypto_box_publickeybytes()), nameof(publicKey)); + ValidateExactBufferLength(privateKey, GetRequiredLength(crypto_box_secretkeybytes()), nameof(privateKey)); + + return Native.crypto_scalarmult_base(publicKey, privateKey); + } + + private static int GetRequiredLength(UIntPtr length) + { + var value = length.ToUInt64(); + if (value > int.MaxValue) + { + throw new OverflowException("The Sodium buffer length exceeds the maximum supported array length."); + } + + return (int)value; + } + + private static void ValidateExactBufferLength(byte[] buffer, int expectedLength, string parameterName) + { + ArgumentNullException.ThrowIfNull(buffer, parameterName); + + if (buffer.Length != expectedLength) + { + throw new ArgumentException($"The buffer must be exactly {expectedLength} bytes.", parameterName); + } + } + + private static void ValidateMinimumBufferLength(byte[] buffer, int minimumLength, string parameterName) + { + ValidateMinimumBufferLength(buffer, (ulong)minimumLength, parameterName); + } + + private static void ValidateMinimumBufferLength(byte[] buffer, ulong minimumLength, string parameterName) + { + ArgumentNullException.ThrowIfNull(buffer, parameterName); + + if ((ulong)buffer.LongLength < minimumLength) + { + throw new ArgumentException($"The buffer must be at least {minimumLength} bytes.", parameterName); + } + } } } diff --git a/PSModule/build.ps1 b/PSModule/build.ps1 index 0cbd84b..738f89a 100644 --- a/PSModule/build.ps1 +++ b/PSModule/build.ps1 @@ -1,3 +1,5 @@ +$ErrorActionPreference = 'Stop' + Remove-Item -Path "$PSScriptRoot/../src/libs" -Recurse -Force -ErrorAction SilentlyContinue $targetRuntimes = @( @@ -10,13 +12,20 @@ $targetRuntimes = @( ) Push-Location $PSScriptRoot -$targetRuntimes | ForEach-Object { - dotnet publish -r $_ - $source = "$PSScriptRoot/bin/Release/net8.0/$_/publish" - $destination = "$PSScriptRoot/../src/libs/$_" - Copy-Item -Path $source -Destination $destination -Recurse -Force +try { + $targetRuntimes | ForEach-Object { + dotnet publish -c Release -r $_ + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed for runtime '$_'." + } + + $source = "$PSScriptRoot/bin/Release/net8.0/$_/publish" + $destination = "$PSScriptRoot/../src/libs/$_" + Copy-Item -Path $source -Destination $destination -Recurse -Force + } +} finally { + Pop-Location } -Pop-Location Get-ChildItem -Path $PSScriptRoot -Directory -Recurse | Where-Object { $_.Name -in 'bin', 'obj' } | ForEach-Object { Write-Warning "Deleting $($_.FullName)" diff --git a/src/functions/private/Assert-VisualCRedistributableInstalled.ps1 b/src/functions/private/Assert-VisualCRedistributableInstalled.ps1 index c5ca9b6..18d2313 100644 --- a/src/functions/private/Assert-VisualCRedistributableInstalled.ps1 +++ b/src/functions/private/Assert-VisualCRedistributableInstalled.ps1 @@ -22,26 +22,37 @@ function Assert-VisualCRedistributableInstalled { #> [CmdletBinding()] [OutputType([bool])] - param ( + param( # The minimum required version of the Visual C++ Redistributable. [Parameter(Mandatory)] - [Version] $Version + [Version] $Version, + + # The process architecture that determines which Redistributable runtime is required. + [Parameter()] + [ValidateSet('X64', 'X86')] + [string] $Architecture = $(if ([System.Environment]::Is64BitProcess) { 'X64' } else { 'X86' }) ) - process { + begin { $result = $false + } + + process { if ($IsWindows) { - $key = 'HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\X64' - if (Test-Path -Path $key) { - $installedVersion = (Get-ItemProperty -Path $key).Version - $result = [Version]($installedVersion.SubString(1, $installedVersion.Length - 1)) -ge $Version + $key = "HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\$Architecture" + $runtimeInfo = Get-ItemProperty -Path $key -ErrorAction SilentlyContinue + if ($runtimeInfo -and $runtimeInfo.Version -and ($runtimeInfo.Installed -ne 0)) { + $installedVersion = [Version]$runtimeInfo.Version.TrimStart('v', 'V') + $result = $installedVersion -ge $Version } } if (-not $result) { - Write-Warning 'The Visual C++ Redistributable for Visual Studio 2015 or later is required.' + Write-Warning "The Visual C++ Redistributable for Visual Studio 2015 or later ($Architecture) is required." Write-Warning 'Download and install the appropriate version from:' Write-Warning ' - https://support.microsoft.com/en-us/help/2977003/the-latest-supported-visual-c-downloads' } $result } + + end {} } diff --git a/src/functions/private/Initialize-Sodium.ps1 b/src/functions/private/Initialize-Sodium.ps1 new file mode 100644 index 0000000..7c5dd1b --- /dev/null +++ b/src/functions/private/Initialize-Sodium.ps1 @@ -0,0 +1,34 @@ +function Initialize-Sodium { + <# + .SYNOPSIS + Initializes Sodium for cryptographic operations. + + .DESCRIPTION + Initializes the native Sodium library once per module session and caches fixed buffer sizes used by the public commands. + + .NOTES + Requires the platform-specific PSModule.Sodium assembly and native libsodium runtime to be loaded. + #> + [OutputType([void])] + [CmdletBinding()] + param() + + begin {} + + process { + if (-not $script:Supported) { throw 'Sodium is not supported on this platform.' } + if ($script:SodiumInitialized) { return } + + $initializationResult = [PSModule.Sodium]::sodium_init() + 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:SodiumInitialized = $true + } + + end {} +} diff --git a/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 b/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 index 0fcaf62..6121177 100644 --- a/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 +++ b/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 @@ -55,6 +55,7 @@ ValueFromPipelineByPropertyName )] [Alias('CipherText')] + [ValidateNotNullOrEmpty()] [string] $SealedBox, # The base64-encoded public key used for decryption. @@ -63,38 +64,55 @@ # The base64-encoded private key used for decryption. [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $PrivateKey ) begin { - if (-not $script:Supported) { throw 'Sodium is not supported on this platform.' } - $null = [PSModule.Sodium]::sodium_init() + Initialize-Sodium } process { - $ciphertext = [System.Convert]::FromBase64String($SealedBox) - - $privateKeyByteArray = [System.Convert]::FromBase64String($PrivateKey) - if ($privateKeyByteArray.Length -ne 32) { throw 'Invalid private key.' } - - if ([string]::IsNullOrWhiteSpace($PublicKey)) { - $publicKeyByteArray = Get-SodiumPublicKey -PrivateKey $PrivateKey -AsByteArray - } else { - $publicKeyByteArray = [System.Convert]::FromBase64String($PublicKey) - if ($publicKeyByteArray.Length -ne 32) { throw 'Invalid public key.' } + $privateKeyByteArray = $null + $decryptedBytes = $null + try { + $ciphertext = [System.Convert]::FromBase64String($SealedBox) + if ($ciphertext.Length -lt $script:SodiumSealBytes) { + throw "Invalid sealed box. Expected at least $script:SodiumSealBytes bytes but got $($ciphertext.Length)." + } + + $privateKeyByteArray = [System.Convert]::FromBase64String($PrivateKey) + if ($privateKeyByteArray.Length -ne $script:SodiumPrivateKeyBytes) { + throw "Invalid private key. Expected $script:SodiumPrivateKeyBytes bytes but got $($privateKeyByteArray.Length)." + } + + if (-not $PublicKey) { + $publicKeyByteArray = [byte[]](Get-SodiumPublicKey -PrivateKey $PrivateKey -AsByteArray) + } else { + $publicKeyByteArray = [System.Convert]::FromBase64String($PublicKey) + if ($publicKeyByteArray.Length -ne $script:SodiumPublicKeyBytes) { + throw "Invalid public key. Expected $script:SodiumPublicKeyBytes bytes but got $($publicKeyByteArray.Length)." + } + } + + $decryptedBytes = [byte[]]::new($ciphertext.Length - $script:SodiumSealBytes) + + $result = [PSModule.Sodium]::crypto_box_seal_open( + $decryptedBytes, $ciphertext, [UInt64]$ciphertext.Length, $publicKeyByteArray, $privateKeyByteArray + ) + + if ($result -ne 0) { + throw 'Decryption failed.' + } + + return [System.Text.Encoding]::UTF8.GetString($decryptedBytes) + } finally { + if ($null -ne $privateKeyByteArray -and $privateKeyByteArray.Length -gt 0) { + [array]::Clear($privateKeyByteArray, 0, $privateKeyByteArray.Length) + } + if ($null -ne $decryptedBytes -and $decryptedBytes.Length -gt 0) { + [array]::Clear($decryptedBytes, 0, $decryptedBytes.Length) + } } - - $overhead = [PSModule.Sodium]::crypto_box_sealbytes().ToUInt32() - $decryptedBytes = New-Object byte[] ($ciphertext.Length - $overhead) - - $result = [PSModule.Sodium]::crypto_box_seal_open( - $decryptedBytes, $ciphertext, [UInt64]$ciphertext.Length, $publicKeyByteArray, $privateKeyByteArray - ) - - if ($result -ne 0) { - throw 'Decryption failed.' - } - - return [System.Text.Encoding]::UTF8.GetString($decryptedBytes) } } diff --git a/src/functions/public/ConvertTo-SodiumSealedBox.ps1 b/src/functions/public/ConvertTo-SodiumSealedBox.ps1 index b76a089..613d172 100644 --- a/src/functions/public/ConvertTo-SodiumSealedBox.ps1 +++ b/src/functions/public/ConvertTo-SodiumSealedBox.ps1 @@ -52,36 +52,36 @@ # The base64-encoded public key used for encryption. [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $PublicKey ) begin { - if (-not $script:Supported) { throw 'Sodium is not supported on this platform.' } - $null = [PSModule.Sodium]::sodium_init() + Initialize-Sodium } process { - # Convert public key from Base64 or space-separated string + $messageBytes = $null try { $publicKeyByteArray = [Convert]::FromBase64String($PublicKey) - } catch { - $PSCmdlet.ThrowTerminatingError($_) - } - if ($publicKeyByteArray.Length -ne 32) { - throw "Invalid public key. Expected 32 bytes but got $($publicKeyByteArray.Length)." - } + if ($publicKeyByteArray.Length -ne $script:SodiumPublicKeyBytes) { + throw "Invalid public key. Expected $script:SodiumPublicKeyBytes bytes but got $($publicKeyByteArray.Length)." + } - $messageBytes = [System.Text.Encoding]::UTF8.GetBytes($Message) - $overhead = [PSModule.Sodium]::crypto_box_sealbytes().ToUInt32() - $cipherLength = $messageBytes.Length + $overhead - $ciphertext = New-Object byte[] $cipherLength + $messageBytes = [System.Text.Encoding]::UTF8.GetBytes($Message) + $cipherLength = $messageBytes.Length + $script:SodiumSealBytes + $ciphertext = [byte[]]::new($cipherLength) - # Encrypt message - $result = [PSModule.Sodium]::crypto_box_seal($ciphertext, $messageBytes, [uint64]$messageBytes.Length, $publicKeyByteArray) + $result = [PSModule.Sodium]::crypto_box_seal($ciphertext, $messageBytes, [uint64]$messageBytes.Length, $publicKeyByteArray) - if ($result -ne 0) { - throw 'Encryption failed.' - } + if ($result -ne 0) { + throw 'Encryption failed.' + } - return [Convert]::ToBase64String($ciphertext) + return [Convert]::ToBase64String($ciphertext) + } finally { + if ($null -ne $messageBytes -and $messageBytes.Length -gt 0) { + [array]::Clear($messageBytes, 0, $messageBytes.Length) + } + } } } diff --git a/src/functions/public/Get-SodiumPublicKey.ps1 b/src/functions/public/Get-SodiumPublicKey.ps1 index fc75cef..0f3dc0a 100644 --- a/src/functions/public/Get-SodiumPublicKey.ps1 +++ b/src/functions/public/Get-SodiumPublicKey.ps1 @@ -70,10 +70,10 @@ [OutputType([string], ParameterSetName = 'Base64')] [OutputType([byte[]], ParameterSetName = 'AsByteArray')] [CmdletBinding(DefaultParameterSetName = 'Base64')] - [CmdletBinding()] param( # The private key to derive the public key from. [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $PrivateKey, # Returns the byte array @@ -82,22 +82,32 @@ ) begin { - if (-not $script:Supported) { throw 'Sodium is not supported on this platform.' } - $null = [PSModule.Sodium]::sodium_init() + Initialize-Sodium } process { - $publicKeyByteArray = New-Object byte[] 32 - $privateKeyByteArray = [System.Convert]::FromBase64String($PrivateKey) - $rc = [PSModule.Sodium]::crypto_scalarmult_base($publicKeyByteArray, $privateKeyByteArray) - if ($rc -ne 0) { throw 'Unable to derive public key from private key.' } - } + $privateKeyByteArray = $null + try { + $publicKeyByteArray = [byte[]]::new($script:SodiumPublicKeyBytes) + $privateKeyByteArray = [System.Convert]::FromBase64String($PrivateKey) + if ($privateKeyByteArray.Length -ne $script:SodiumPrivateKeyBytes) { + throw "Invalid private key. Expected $script:SodiumPrivateKeyBytes bytes but got $($privateKeyByteArray.Length)." + } + + $deriveResult = [PSModule.Sodium]::crypto_scalarmult_base($publicKeyByteArray, $privateKeyByteArray) + if ($deriveResult -ne 0) { throw 'Unable to derive public key from private key.' } - end { - if ($AsByteArray) { - return $publicKeyByteArray - } else { - return [System.Convert]::ToBase64String($publicKeyByteArray) + if ($AsByteArray) { + return $publicKeyByteArray + } else { + return [System.Convert]::ToBase64String($publicKeyByteArray) + } + } finally { + if ($null -ne $privateKeyByteArray -and $privateKeyByteArray.Length -gt 0) { + [array]::Clear($privateKeyByteArray, 0, $privateKeyByteArray.Length) + } } } + + end {} } diff --git a/src/functions/public/New-SodiumKeyPair.ps1 b/src/functions/public/New-SodiumKeyPair.ps1 index c023557..be64f10 100644 --- a/src/functions/public/New-SodiumKeyPair.ps1 +++ b/src/functions/public/New-SodiumKeyPair.ps1 @@ -80,40 +80,55 @@ ParameterSetName = 'SeededKeyPair', ValueFromPipeline )] + [ValidateNotNullOrEmpty()] [string] $Seed ) begin { - if (-not $script:Supported) { throw 'Sodium is not supported on this platform.' } - $null = [PSModule.Sodium]::sodium_init() + Initialize-Sodium } process { - $pkSize = [PSModule.Sodium]::crypto_box_publickeybytes().ToUInt32() - $skSize = [PSModule.Sodium]::crypto_box_secretkeybytes().ToUInt32() - $publicKey = New-Object byte[] $pkSize - $privateKey = New-Object byte[] $skSize - - switch ($PSCmdlet.ParameterSetName) { - 'SeededKeyPair' { - # Derive a 32-byte seed from the provided string seed (using SHA-256) - $seedBytes = [System.Text.Encoding]::UTF8.GetBytes($Seed) - $derivedSeed = [System.Security.Cryptography.SHA256]::Create().ComputeHash($seedBytes) - $result = [PSModule.Sodium]::crypto_box_seed_keypair($publicKey, $privateKey, $derivedSeed) - break + $publicKey = [byte[]]::new($script:SodiumPublicKeyBytes) + $privateKey = [byte[]]::new($script:SodiumPrivateKeyBytes) + $seedBytes = $null + $derivedSeed = $null + try { + switch ($PSCmdlet.ParameterSetName) { + 'SeededKeyPair' { + $seedBytes = [System.Text.Encoding]::UTF8.GetBytes($Seed) + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $derivedSeed = $sha256.ComputeHash($seedBytes) + } finally { + $sha256.Dispose() + } + $result = [PSModule.Sodium]::crypto_box_seed_keypair($publicKey, $privateKey, $derivedSeed) + break + } + default { + $result = [PSModule.Sodium]::crypto_box_keypair($publicKey, $privateKey) + } } - default { - $result = [PSModule.Sodium]::crypto_box_keypair($publicKey, $privateKey) - } - } - if ($result -ne 0) { - throw 'Key pair generation failed.' - } + if ($result -ne 0) { + throw 'Key pair generation failed.' + } - return [pscustomobject]@{ - PublicKey = [Convert]::ToBase64String($publicKey) - PrivateKey = [Convert]::ToBase64String($privateKey) + return [pscustomobject]@{ + PublicKey = [Convert]::ToBase64String($publicKey) + PrivateKey = [Convert]::ToBase64String($privateKey) + } + } finally { + if ($null -ne $privateKey -and $privateKey.Length -gt 0) { + [array]::Clear($privateKey, 0, $privateKey.Length) + } + if ($null -ne $seedBytes -and $seedBytes.Length -gt 0) { + [array]::Clear($seedBytes, 0, $seedBytes.Length) + } + if ($null -ne $derivedSeed -and $derivedSeed.Length -gt 0) { + [array]::Clear($derivedSeed, 0, $derivedSeed.Length) + } } } } diff --git a/src/main.ps1 b/src/main.ps1 index 69b1199..0051b08 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -1,33 +1,43 @@ +$processArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture + switch ($true) { $IsLinux { - $architecture = (uname -m) - if ($architecture -eq 'aarch64') { - Import-Module "$PSScriptRoot/libs/linux-arm64/PSModule.Sodium.dll" - $script:Supported = $true - } elseif ($architecture -eq 'x86_64') { - Import-Module "$PSScriptRoot/libs/linux-x64/PSModule.Sodium.dll" - $script:Supported = $true - } else { - throw "Unsupported Linux architecture: $architecture. Please refer to the documentation for supported architectures." + 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 { - if ("$(sysctl -n machdep.cpu.brand_string)" -Like 'Apple*') { - Import-Module "$PSScriptRoot/libs/osx-arm64/PSModule.Sodium.dll" - } else { - Import-Module "$PSScriptRoot/libs/osx-x64/PSModule.Sodium.dll" + 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." + } } - $script:Supported = $true } $IsWindows { - if ([System.Environment]::Is64BitProcess) { - Import-Module "$PSScriptRoot/libs/win-x64/PSModule.Sodium.dll" - } else { - Import-Module "$PSScriptRoot/libs/win-x86/PSModule.Sodium.dll" + 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." + } } - $script:Supported = Assert-VisualCRedistributableInstalled -Version '14.0' } default { throw 'Unsupported platform. Please refer to the documentation for more information.' } } + +$assemblyPath = Join-Path -Path $PSScriptRoot -ChildPath "libs/$runtimeIdentifier/PSModule.Sodium.dll" +Import-Module $assemblyPath -ErrorAction Stop + +if ($IsWindows) { + $script:Supported = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture $processArchitecture.ToString() +} else { + $script:Supported = $true +} diff --git a/src/variables/private/Initialized.ps1 b/src/variables/private/Initialized.ps1 new file mode 100644 index 0000000..9025085 --- /dev/null +++ b/src/variables/private/Initialized.ps1 @@ -0,0 +1,4 @@ +$script:SodiumInitialized = $false +$script:SodiumPublicKeyBytes = $null +$script:SodiumPrivateKeyBytes = $null +$script:SodiumSealBytes = $null diff --git a/tests/Sodium.Tests.ps1 b/tests/Sodium.Tests.ps1 index bad67a4..b0082e2 100644 --- a/tests/Sodium.Tests.ps1 +++ b/tests/Sodium.Tests.ps1 @@ -39,6 +39,14 @@ { ConvertFrom-SodiumSealedBox -SealedBox $encryptedMessage -PublicKey $invalidPublicKey -PrivateKey $keyPair.PrivateKey } | Should -Throw } + It 'Throws a clear error when the sealed box is shorter than the Sodium overhead' { + $keyPair = New-SodiumKeyPair + $shortSealedBox = [Convert]::ToBase64String([byte[]]::new(16)) + + { ConvertFrom-SodiumSealedBox -SealedBox $shortSealedBox -PrivateKey $keyPair.PrivateKey } | + Should -Throw 'Invalid sealed box. Expected at least 48 bytes but got 16.' + } + It 'Encrypts a message correctly when using pipeline input on ConvertTo-SodiumSealedBox' { $keyPair = New-SodiumKeyPair $publicKey = $keyPair.PublicKey @@ -150,5 +158,12 @@ { Get-SodiumPublicKey -PrivateKey $invalidPrivateKey } | Should -Throw } + + It 'Get-SodiumPublicKey - Throws a clear error when a private key has the wrong length' { + $shortPrivateKey = [Convert]::ToBase64String([byte[]]::new(16)) + + { Get-SodiumPublicKey -PrivateKey $shortPrivateKey } | + Should -Throw 'Invalid private key. Expected 32 bytes but got 16.' + } } } From b5c4f0e90320cda10dbe21ba031ad918c328c873 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 15 May 2026 22:22:32 +0200 Subject: [PATCH 02/22] Fix codespell lint by renaming clen to ciphertextLength in Sodium interop --- PSModule/Sodium/Sodium.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/PSModule/Sodium/Sodium.cs b/PSModule/Sodium/Sodium.cs index 14e7e07..238d3dc 100644 --- a/PSModule/Sodium/Sodium.cs +++ b/PSModule/Sodium/Sodium.cs @@ -25,7 +25,7 @@ private static class Native [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] - public static extern int crypto_box_seal_open(byte[] decrypted, byte[] ciphertext, ulong clen, byte[] publicKey, byte[] privateKey); + public static extern int crypto_box_seal_open(byte[] decrypted, byte[] ciphertext, ulong ciphertextLength, byte[] publicKey, byte[] privateKey); [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] @@ -75,20 +75,20 @@ public static int crypto_box_seal(byte[] ciphertext, byte[] message, ulong mlen, return Native.crypto_box_seal(ciphertext, message, mlen, publicKey); } - public static int crypto_box_seal_open(byte[] decrypted, byte[] ciphertext, ulong clen, byte[] publicKey, byte[] privateKey) + public static int crypto_box_seal_open(byte[] decrypted, byte[] ciphertext, ulong ciphertextLength, byte[] publicKey, byte[] privateKey) { var sealBytes = crypto_box_sealbytes().ToUInt64(); - if (clen < sealBytes) + if (ciphertextLength < sealBytes) { throw new ArgumentException($"The ciphertext must be at least {sealBytes} bytes.", nameof(ciphertext)); } - ValidateMinimumBufferLength(ciphertext, clen, nameof(ciphertext)); - ValidateMinimumBufferLength(decrypted, clen - sealBytes, nameof(decrypted)); + ValidateMinimumBufferLength(ciphertext, ciphertextLength, nameof(ciphertext)); + ValidateMinimumBufferLength(decrypted, ciphertextLength - sealBytes, nameof(decrypted)); ValidateExactBufferLength(publicKey, GetRequiredLength(crypto_box_publickeybytes()), nameof(publicKey)); ValidateExactBufferLength(privateKey, GetRequiredLength(crypto_box_secretkeybytes()), nameof(privateKey)); - return Native.crypto_box_seal_open(decrypted, ciphertext, clen, publicKey, privateKey); + return Native.crypto_box_seal_open(decrypted, ciphertext, ciphertextLength, publicKey, privateKey); } public static UIntPtr crypto_box_publickeybytes() From b18eabcf6f84536dc829bb208c337bd0a037319c Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 12:43:19 +0200 Subject: [PATCH 03/22] Perf: use SHA256.HashData for seeded key derivation (#50) Replaces disposable SHA256 instance with the static SHA256.HashData API in the seeded keypair path. Reduces per-call allocations. --- src/functions/public/New-SodiumKeyPair.ps1 | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/functions/public/New-SodiumKeyPair.ps1 b/src/functions/public/New-SodiumKeyPair.ps1 index be64f10..2fafbc1 100644 --- a/src/functions/public/New-SodiumKeyPair.ps1 +++ b/src/functions/public/New-SodiumKeyPair.ps1 @@ -97,12 +97,7 @@ switch ($PSCmdlet.ParameterSetName) { 'SeededKeyPair' { $seedBytes = [System.Text.Encoding]::UTF8.GetBytes($Seed) - $sha256 = [System.Security.Cryptography.SHA256]::Create() - try { - $derivedSeed = $sha256.ComputeHash($seedBytes) - } finally { - $sha256.Dispose() - } + $derivedSeed = [System.Security.Cryptography.SHA256]::HashData($seedBytes) $result = [PSModule.Sodium]::crypto_box_seed_keypair($publicKey, $privateKey, $derivedSeed) break } From ca2877a2aa2c86e9e15b7a533ab0f36e02b66d57 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 12:52:05 +0200 Subject: [PATCH 04/22] Perf: defer Windows VC++ runtime probe to init failure path (#54) Skips the registry probe during module import; only runs it as a diagnostic if native sodium_init() throws on Windows. Reduces cold-start overhead. --- src/functions/private/Initialize-Sodium.ps1 | 9 ++++++++- src/main.ps1 | 8 +++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/functions/private/Initialize-Sodium.ps1 b/src/functions/private/Initialize-Sodium.ps1 index 7c5dd1b..7c02751 100644 --- a/src/functions/private/Initialize-Sodium.ps1 +++ b/src/functions/private/Initialize-Sodium.ps1 @@ -19,7 +19,14 @@ if (-not $script:Supported) { throw 'Sodium is not supported on this platform.' } if ($script:SodiumInitialized) { return } - $initializationResult = [PSModule.Sodium]::sodium_init() + try { + $initializationResult = [PSModule.Sodium]::sodium_init() + } catch { + if ($IsWindows) { + $script:Supported = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture $script:ProcessArchitecture + } + throw + } if ($initializationResult -lt 0) { throw 'Sodium initialization failed.' } diff --git a/src/main.ps1 b/src/main.ps1 index 0051b08..18af443 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -36,8 +36,6 @@ switch ($true) { $assemblyPath = Join-Path -Path $PSScriptRoot -ChildPath "libs/$runtimeIdentifier/PSModule.Sodium.dll" Import-Module $assemblyPath -ErrorAction Stop -if ($IsWindows) { - $script:Supported = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture $processArchitecture.ToString() -} else { - $script:Supported = $true -} +# Optimistically mark supported; Initialize-Sodium will run the Windows VC++ runtime check lazily only if native init fails. +$script:Supported = $true +$script:ProcessArchitecture = $processArchitecture.ToString() From 6a789b0ae5403bfe20a9a1c11ed4140cefb5cce6 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 12:57:17 +0200 Subject: [PATCH 05/22] test: cover Assert-VisualCRedistributableInstalled to maintain coverage after lazy probe Adds InModuleScope tests for the private VC++ runtime probe so the deferred-init refactor (#54) does not regress code coverage. --- tests/Sodium.Tests.ps1 | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Sodium.Tests.ps1 b/tests/Sodium.Tests.ps1 index b0082e2..00d017f 100644 --- a/tests/Sodium.Tests.ps1 +++ b/tests/Sodium.Tests.ps1 @@ -166,4 +166,27 @@ Should -Throw 'Invalid private key. Expected 32 bytes but got 16.' } } + + Context 'Runtime diagnostics' { + It 'Assert-VisualCRedistributableInstalled returns a boolean for the current architecture' { + InModuleScope Sodium { + $arch = if ([System.Environment]::Is64BitProcess) { 'X64' } else { 'X86' } + $result = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture $arch + $result | Should -BeOfType [bool] + } + } + + It 'Assert-VisualCRedistributableInstalled treats non-Windows platforms as unsupported' { + InModuleScope Sodium { + $original = $IsWindows + try { + Set-Variable -Name IsWindows -Scope Script -Value $false + $result = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture 'X64' 3>$null + $result | Should -BeFalse + } finally { + Set-Variable -Name IsWindows -Scope Script -Value $original + } + } + } + } } From 5ef109ae452f42b996617a43fd72e20f3746fc5a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 13:05:12 +0200 Subject: [PATCH 06/22] test: simplify VC++ runtime probe coverage test Removes the platform-flip test which destabilized the Windows Lint-Module step. Keeps the basic call to maintain code coverage for the deferred-init path. --- tests/Sodium.Tests.ps1 | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/tests/Sodium.Tests.ps1 b/tests/Sodium.Tests.ps1 index 00d017f..e8a00bc 100644 --- a/tests/Sodium.Tests.ps1 +++ b/tests/Sodium.Tests.ps1 @@ -171,22 +171,9 @@ It 'Assert-VisualCRedistributableInstalled returns a boolean for the current architecture' { InModuleScope Sodium { $arch = if ([System.Environment]::Is64BitProcess) { 'X64' } else { 'X86' } - $result = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture $arch + $result = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture $arch 3>$null $result | Should -BeOfType [bool] } } - - It 'Assert-VisualCRedistributableInstalled treats non-Windows platforms as unsupported' { - InModuleScope Sodium { - $original = $IsWindows - try { - Set-Variable -Name IsWindows -Scope Script -Value $false - $result = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture 'X64' 3>$null - $result | Should -BeFalse - } finally { - Set-Variable -Name IsWindows -Scope Script -Value $original - } - } - } } } From a18d1595aef45a708d95688e6d545119577ed937 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 13:10:34 +0200 Subject: [PATCH 07/22] perf(#49): inline crypto_scalarmult_base in ConvertFrom-SodiumSealedBox Avoids the cost of re-entering Get-SodiumPublicKey (full cmdlet binder pass plus a redundant base64 round-trip) when -PublicKey is not supplied. Refs #49. --- src/functions/public/ConvertFrom-SodiumSealedBox.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 b/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 index 6121177..78af8d7 100644 --- a/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 +++ b/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 @@ -87,7 +87,11 @@ } if (-not $PublicKey) { - $publicKeyByteArray = [byte[]](Get-SodiumPublicKey -PrivateKey $PrivateKey -AsByteArray) + $publicKeyByteArray = [byte[]]::new($script:SodiumPublicKeyBytes) + $deriveResult = [PSModule.Sodium]::crypto_scalarmult_base($publicKeyByteArray, $privateKeyByteArray) + if ($deriveResult -ne 0) { + throw 'Unable to derive public key from private key.' + } } else { $publicKeyByteArray = [System.Convert]::FromBase64String($PublicKey) if ($publicKeyByteArray.Length -ne $script:SodiumPublicKeyBytes) { From d0b7961cc7b76f1f1ddcfc4e90f407584189d90a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 13:16:32 +0200 Subject: [PATCH 08/22] perf(#53): initialize Sodium at module import Calls Initialize-Sodium once during module load instead of in every cmdlet's begin{} block, removing a private-function dispatch per invocation. Refs #53. --- .../public/ConvertFrom-SodiumSealedBox.ps1 | 4 +- .../public/ConvertTo-SodiumSealedBox.ps1 | 4 +- src/functions/public/Get-SodiumPublicKey.ps1 | 4 +- src/functions/public/New-SodiumKeyPair.ps1 | 4 +- src/main.ps1 | 2 + tools/Measure-Speed.ps1 | 235 ++++++++++++++++++ tools/perf/Invoke-Benchmark.ps1 | 168 +++++++++++++ tools/perf/Wait-ForPrerelease.ps1 | 94 +++++++ tools/perf/results.jsonl | 4 + 9 files changed, 507 insertions(+), 12 deletions(-) create mode 100644 tools/Measure-Speed.ps1 create mode 100644 tools/perf/Invoke-Benchmark.ps1 create mode 100644 tools/perf/Wait-ForPrerelease.ps1 create mode 100644 tools/perf/results.jsonl diff --git a/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 b/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 index 78af8d7..b13f8e3 100644 --- a/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 +++ b/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 @@ -68,9 +68,7 @@ [string] $PrivateKey ) - begin { - Initialize-Sodium - } + begin {} process { $privateKeyByteArray = $null diff --git a/src/functions/public/ConvertTo-SodiumSealedBox.ps1 b/src/functions/public/ConvertTo-SodiumSealedBox.ps1 index 613d172..6b8f235 100644 --- a/src/functions/public/ConvertTo-SodiumSealedBox.ps1 +++ b/src/functions/public/ConvertTo-SodiumSealedBox.ps1 @@ -55,9 +55,7 @@ [ValidateNotNullOrEmpty()] [string] $PublicKey ) - begin { - Initialize-Sodium - } + begin {} process { $messageBytes = $null diff --git a/src/functions/public/Get-SodiumPublicKey.ps1 b/src/functions/public/Get-SodiumPublicKey.ps1 index 0f3dc0a..46f2096 100644 --- a/src/functions/public/Get-SodiumPublicKey.ps1 +++ b/src/functions/public/Get-SodiumPublicKey.ps1 @@ -81,9 +81,7 @@ [switch] $AsByteArray ) - begin { - Initialize-Sodium - } + begin {} process { $privateKeyByteArray = $null diff --git a/src/functions/public/New-SodiumKeyPair.ps1 b/src/functions/public/New-SodiumKeyPair.ps1 index 2fafbc1..44fb29f 100644 --- a/src/functions/public/New-SodiumKeyPair.ps1 +++ b/src/functions/public/New-SodiumKeyPair.ps1 @@ -84,9 +84,7 @@ [string] $Seed ) - begin { - Initialize-Sodium - } + begin {} process { $publicKey = [byte[]]::new($script:SodiumPublicKeyBytes) diff --git a/src/main.ps1 b/src/main.ps1 index 18af443..71987f6 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -39,3 +39,5 @@ Import-Module $assemblyPath -ErrorAction Stop # Optimistically mark supported; Initialize-Sodium will run the Windows VC++ runtime check lazily only if native init fails. $script:Supported = $true $script:ProcessArchitecture = $processArchitecture.ToString() + +Initialize-Sodium diff --git a/tools/Measure-Speed.ps1 b/tools/Measure-Speed.ps1 new file mode 100644 index 0000000..c8f57ab --- /dev/null +++ b/tools/Measure-Speed.ps1 @@ -0,0 +1,235 @@ +[CmdletBinding()] +param( + [int] $Iterations = 1000, + [switch] $Child, + [string] $Version, + [switch] $Prerelease +) + +$ErrorActionPreference = 'Stop' + +function Ensure-SodiumVersionInstalled { + param( + [Parameter(Mandatory)] + [string] $Version, + + [Parameter(Mandatory)] + [bool] $Prerelease + ) + + $installed = Get-Module -ListAvailable -Name Sodium | + Where-Object { $_.Version.ToString() -eq $Version } + + if ($installed) { + return + } + + $installParameters = @{ + Name = 'Sodium' + Repository = 'PSGallery' + Scope = 'CurrentUser' + TrustRepository = $true + AcceptLicense = $true + SkipDependencyCheck = $true + Quiet = $true + } + + if ($Prerelease) { + $installParameters.Prerelease = $true + } else { + $installParameters.Version = $Version + } + + Install-PSResource @installParameters +} + +function Invoke-SodiumBenchmarks { + param( + [Parameter(Mandatory)] + [string] $Version, + + [Parameter(Mandatory)] + [int] $Iterations + ) + + Import-Module Sodium -RequiredVersion $Version -Force -ErrorAction Stop + + $keyPair = New-SodiumKeyPair + $message = 'The quick brown fox jumps over the lazy dog.' + $publicKey = $keyPair.PublicKey + $privateKey = $keyPair.PrivateKey + $seed = 'DeterministicSeed' + $sealedBox = ConvertTo-SodiumSealedBox -Message $message -PublicKey $publicKey + + $warmup = @( + { New-SodiumKeyPair | Out-Null }, + { New-SodiumKeyPair -Seed $seed | Out-Null }, + { Get-SodiumPublicKey -PrivateKey $privateKey | Out-Null }, + { ConvertTo-SodiumSealedBox -Message $message -PublicKey $publicKey | Out-Null }, + { ConvertFrom-SodiumSealedBox -SealedBox $sealedBox -PrivateKey $privateKey | Out-Null } + ) + + foreach ($step in $warmup) { + & $step + } + + function Measure-Loop { + param( + [Parameter(Mandatory)] + [string] $Name, + + [Parameter(Mandatory)] + [scriptblock] $Body + ) + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + for ($index = 0; $index -lt $Iterations; $index++) { + & $Body | Out-Null + } + $stopwatch.Stop() + + [pscustomobject]@{ + Version = $Version + Benchmark = $Name + Iterations = $Iterations + TotalMilliseconds = [math]::Round($stopwatch.Elapsed.TotalMilliseconds, 3) + MeanMicroseconds = [math]::Round(($stopwatch.Elapsed.TotalMilliseconds * 1000) / $Iterations, 3) + } + } + + @( + Measure-Loop -Name 'New-SodiumKeyPair' -Body { New-SodiumKeyPair } + Measure-Loop -Name 'New-SodiumKeyPair-Seeded' -Body { New-SodiumKeyPair -Seed $seed } + Measure-Loop -Name 'Get-SodiumPublicKey' -Body { Get-SodiumPublicKey -PrivateKey $privateKey } + Measure-Loop -Name 'ConvertTo-SodiumSealedBox' -Body { ConvertTo-SodiumSealedBox -Message $message -PublicKey $publicKey } + Measure-Loop -Name 'ConvertFrom-SodiumSealedBox' -Body { ConvertFrom-SodiumSealedBox -SealedBox $sealedBox -PrivateKey $privateKey } + ) +} + +function Invoke-SodiumColdStartBenchmarks { + param( + [Parameter(Mandatory)] + [string] $Version, + + [Parameter(Mandatory)] + [int] $Iterations + ) + + $childScript = @' +param( + [Parameter(Mandatory)] + [string] $Version +) + +$ErrorActionPreference = ''Stop'' +Import-Module Sodium -RequiredVersion $Version -Force -ErrorAction Stop + +$keyPair = New-SodiumKeyPair +$message = ''The quick brown fox jumps over the lazy dog.'' +$publicKey = $keyPair.PublicKey +$privateKey = $keyPair.PrivateKey +$seed = ''DeterministicSeed'' +$sealedBox = ConvertTo-SodiumSealedBox -Message $message -PublicKey $publicKey + +New-SodiumKeyPair | Out-Null +New-SodiumKeyPair -Seed $seed | Out-Null +Get-SodiumPublicKey -PrivateKey $privateKey | Out-Null +ConvertTo-SodiumSealedBox -Message $message -PublicKey $publicKey | Out-Null +ConvertFrom-SodiumSealedBox -SealedBox $sealedBox -PrivateKey $privateKey | Out-Null +'@ + + $results = for ($index = 0; $index -lt $Iterations; $index++) { + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + & pwsh -NoProfile -Command $childScript -Version $Version | Out-Null + $stopwatch.Stop() + + [pscustomobject]@{ + Version = $Version + Benchmark = 'ColdStart-Import-CommandSuite' + Iterations = 1 + TotalMilliseconds = [math]::Round($stopwatch.Elapsed.TotalMilliseconds, 3) + MeanMicroseconds = [math]::Round($stopwatch.Elapsed.TotalMilliseconds * 1000, 3) + } + } + + $results +} + +if ($Child) { + Invoke-SodiumBenchmarks -Version $Version -Iterations $Iterations | ConvertTo-Json -Depth 4 + return +} + +$targets = @( + [pscustomobject]@{ + Label = 'stable' + Version = '2.2.2' + Prerelease = $false + } + [pscustomobject]@{ + Label = 'preview' + Version = '2.2.3' + Prerelease = $true + } +) + +foreach ($target in $targets) { + Ensure-SodiumVersionInstalled -Version $target.Version -Prerelease $target.Prerelease +} + +$results = foreach ($target in $targets) { + $childOutput = & pwsh -NoProfile -File $PSCommandPath -Child -Version $target.Version -Iterations $Iterations + $childOutput | ConvertFrom-Json +} + +$coldStartResults = foreach ($target in $targets) { + Invoke-SodiumColdStartBenchmarks -Version $target.Version -Iterations $Iterations +} + +$results | + Sort-Object Benchmark, Version | + Select-Object Version, Benchmark, Iterations, TotalMilliseconds, MeanMicroseconds | + Format-Table -AutoSize + +$coldStartResults | + Group-Object Version | + ForEach-Object { + $groupAverage = [math]::Round(($_.Group | Measure-Object -Property TotalMilliseconds -Average).Average, 3) + [pscustomobject]@{ + Version = $_.Name + Benchmark = 'ColdStart-Import-CommandSuite' + Iterations = $_.Count + AverageMilliseconds = $groupAverage + AverageMicroseconds = [math]::Round($groupAverage * 1000, 3) + } + } | + Sort-Object Version | + Format-Table -AutoSize + +$comparison = $results | + Group-Object Benchmark | + ForEach-Object { + $stable = $_.Group | Where-Object { $_.Version -eq '2.2.2' } + $preview = $_.Group | Where-Object { $_.Version -eq '2.2.3' } + + [pscustomobject]@{ + Benchmark = $_.Name + StableMs = $stable.TotalMilliseconds + PreviewMs = $preview.TotalMilliseconds + DeltaMs = [math]::Round(($stable.TotalMilliseconds - $preview.TotalMilliseconds), 3) + Percent = [math]::Round((($stable.TotalMilliseconds - $preview.TotalMilliseconds) / $stable.TotalMilliseconds) * 100, 2) + } + } + +$comparison | Sort-Object Benchmark | Format-Table -AutoSize + +$coldComparison = $coldStartResults | + Group-Object Version | + ForEach-Object { + [pscustomobject]@{ + Version = $_.Name + AverageMilliseconds = [math]::Round(($_.Group | Measure-Object -Property TotalMilliseconds -Average).Average, 3) + } + } + +$coldComparison | Sort-Object Version | Format-Table -AutoSize \ No newline at end of file diff --git a/tools/perf/Invoke-Benchmark.ps1 b/tools/perf/Invoke-Benchmark.ps1 new file mode 100644 index 0000000..ef29df7 --- /dev/null +++ b/tools/perf/Invoke-Benchmark.ps1 @@ -0,0 +1,168 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $Version, + + [Parameter(Mandatory)] + [string] $Label, + + [Parameter()] + [int] $Iterations = 1000, + + [Parameter()] + [int] $Trials = 5, + + [Parameter()] + [int] $ColdStartIterations = 3, + + [Parameter()] + [string] $ResultsPath = (Join-Path $PSScriptRoot 'results.jsonl') +) + +$ErrorActionPreference = 'Stop' + +function Install-IfMissing { + param([string] $Version) + + $base = ($Version -split '-',2)[0] + $pre = if ($Version.Contains('-')) { ($Version -split '-',2)[1] } else { '' } + + $installed = Get-Module -ListAvailable -Name Sodium | Where-Object { + $_.Version.ToString() -eq $base -and ($_.PrivateData.PSData.Prerelease -as [string]) -eq $pre + } + if ($installed) { return } + + $params = @{ + Name = 'Sodium' + Repository = 'PSGallery' + Version = $Version + Scope = 'CurrentUser' + TrustRepository = $true + AcceptLicense = $true + Prerelease = $true + Reinstall = $true + } + [Console]::Error.WriteLine("Installing Sodium $Version from PSGallery...") + Install-PSResource @params +} + +function Run-WarmTrial { + param([string] $Version, [int] $Iterations) + + $script = @" +param([string] `$Version, [int] `$Iterations) +`$ErrorActionPreference = 'Stop' +`$base = (`$Version -split '-',2)[0] +Import-Module Sodium -RequiredVersion `$base -Force -ErrorAction Stop + +`$keyPair = New-SodiumKeyPair +`$message = 'The quick brown fox jumps over the lazy dog.' +`$publicKey = `$keyPair.PublicKey +`$privateKey = `$keyPair.PrivateKey +`$seed = 'DeterministicSeed' +`$sealedBox = ConvertTo-SodiumSealedBox -Message `$message -PublicKey `$publicKey + +1..10 | ForEach-Object { + `$null = New-SodiumKeyPair + `$null = New-SodiumKeyPair -Seed `$seed + `$null = Get-SodiumPublicKey -PrivateKey `$privateKey + `$null = ConvertTo-SodiumSealedBox -Message `$message -PublicKey `$publicKey + `$null = ConvertFrom-SodiumSealedBox -SealedBox `$sealedBox -PrivateKey `$privateKey +} + +function Measure-Loop { + param([string] `$Name, [scriptblock] `$Body, [int] `$Iterations) + [System.GC]::Collect(); [System.GC]::WaitForPendingFinalizers(); [System.GC]::Collect() + `$sw = [System.Diagnostics.Stopwatch]::StartNew() + for (`$i = 0; `$i -lt `$Iterations; `$i++) { `$null = & `$Body } + `$sw.Stop() + [pscustomobject]@{ + Benchmark = `$Name + Iterations = `$Iterations + TotalMs = [math]::Round(`$sw.Elapsed.TotalMilliseconds, 3) + MeanUs = [math]::Round((`$sw.Elapsed.TotalMilliseconds * 1000) / `$Iterations, 3) + } +} + +@( + Measure-Loop 'New-SodiumKeyPair' { New-SodiumKeyPair } `$Iterations + Measure-Loop 'New-SodiumKeyPair-Seeded' { New-SodiumKeyPair -Seed `$seed } `$Iterations + Measure-Loop 'Get-SodiumPublicKey' { Get-SodiumPublicKey -PrivateKey `$privateKey } `$Iterations + Measure-Loop 'ConvertTo-SodiumSealedBox' { ConvertTo-SodiumSealedBox -Message `$message -PublicKey `$publicKey } `$Iterations + Measure-Loop 'ConvertFrom-SodiumSealedBox' { ConvertFrom-SodiumSealedBox -SealedBox `$sealedBox -PrivateKey `$privateKey } `$Iterations +) | ConvertTo-Json -Depth 4 +"@ + + $tmp = [System.IO.Path]::GetTempFileName() + '.ps1' + Set-Content -LiteralPath $tmp -Value $script -Encoding UTF8 + try { + $output = & pwsh -NoProfile -File $tmp -Version $Version -Iterations $Iterations 2>&1 + if ($LASTEXITCODE -ne 0) { throw ("Child pwsh failed: " + ($output -join "`n")) } + $json = ($output | Where-Object { $_ -is [string] }) -join "`n" + return ($json | ConvertFrom-Json) + } finally { + Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue + } +} + +function Run-ColdStartBenchmark { + param([string] $Version, [int] $Iterations) + + $base = ($Version -split '-',2)[0] + $totals = for ($i = 0; $i -lt $Iterations; $i++) { + $sw = [System.Diagnostics.Stopwatch]::StartNew() + & pwsh -NoProfile -Command "Import-Module Sodium -RequiredVersion $base -Force; `$null = New-SodiumKeyPair" | Out-Null + $sw.Stop() + $sw.Elapsed.TotalMilliseconds + } + $sorted = $totals | Sort-Object + [pscustomobject]@{ + Benchmark = 'ColdStart-Import+OneKeyPair' + Iterations = $Iterations + MinMs = [math]::Round((($totals | Measure-Object -Minimum).Minimum), 3) + MedianMs = [math]::Round($sorted[[math]::Floor($sorted.Count/2)], 3) + MeanMs = [math]::Round((($totals | Measure-Object -Average).Average), 3) + } +} + +Install-IfMissing -Version $Version + +[Console]::Error.WriteLine("Running $Trials trials of $Iterations iterations for $Version [$Label]...") +$trialResults = [System.Collections.Generic.List[object]]::new() +for ($t = 1; $t -le $Trials; $t++) { + [Console]::Error.WriteLine(" trial $t/$Trials") + $trialResults.Add((Run-WarmTrial -Version $Version -Iterations $Iterations)) +} + +$benchmarkNames = $trialResults[0] | ForEach-Object { $_.Benchmark } +$aggregated = foreach ($name in $benchmarkNames) { + $samples = foreach ($trial in $trialResults) { ($trial | Where-Object Benchmark -EQ $name).MeanUs } + $sorted = @($samples | Sort-Object) + [pscustomobject]@{ + Benchmark = $name + Iterations = $Iterations + Trials = $Trials + MinUs = [math]::Round((($sorted | Measure-Object -Minimum).Minimum), 3) + MedianUs = [math]::Round($sorted[[math]::Floor($sorted.Count/2)], 3) + MeanUs = [math]::Round((($sorted | Measure-Object -Average).Average), 3) + Samples = $samples + } +} + +[Console]::Error.WriteLine("Running cold-start benchmark (iterations=$ColdStartIterations) for $Version [$Label]...") +$cold = Run-ColdStartBenchmark -Version $Version -Iterations $ColdStartIterations + +$record = [pscustomobject]@{ + Timestamp = (Get-Date).ToUniversalTime().ToString('o') + Label = $Label + Version = $Version + Benchmarks = @($aggregated) + @($cold) +} + +$record.Benchmarks | + Select-Object Benchmark, Iterations, Trials, MinUs, MedianUs, MeanUs, MinMs, MedianMs, MeanMs | + Format-Table -AutoSize | Out-String | Write-Host + +$json = $record | ConvertTo-Json -Depth 6 -Compress +Add-Content -LiteralPath $ResultsPath -Value $json +[Console]::Error.WriteLine("Appended to $ResultsPath") diff --git a/tools/perf/Wait-ForPrerelease.ps1 b/tools/perf/Wait-ForPrerelease.ps1 new file mode 100644 index 0000000..453d72c --- /dev/null +++ b/tools/perf/Wait-ForPrerelease.ps1 @@ -0,0 +1,94 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $HeadSha, + + [Parameter()] + [string] $Repo = 'PSModule/Sodium', + + [Parameter()] + [string] $WorkflowName = 'Process-PSModule', + + [Parameter()] + [int] $TimeoutSeconds = 1500, + + [Parameter()] + [int] $PollSeconds = 20, + + [Parameter()] + [string] $PackageName = 'Sodium' +) + +$ErrorActionPreference = 'Stop' + +function Write-Progress2 { param([string] $Message) [Console]::Error.WriteLine($Message) } + +function Get-LatestRun { + param([string] $HeadSha) + $json = gh run list --repo $Repo --branch fix/44-harden-sodium-interop --limit 20 --json status,conclusion,name,headSha,databaseId,createdAt + if ($LASTEXITCODE -ne 0) { throw "gh run list failed" } + $runs = $json | ConvertFrom-Json + $runs | Where-Object { $_.headSha -eq $HeadSha -and $_.name -eq $WorkflowName } | Sort-Object createdAt -Descending | Select-Object -First 1 +} + +$start = Get-Date +Write-Progress2 "Waiting for workflow run on $HeadSha..." +$run = $null +while ((Get-Date) - $start -lt [TimeSpan]::FromSeconds($TimeoutSeconds)) { + $run = Get-LatestRun -HeadSha $HeadSha + if ($run) { break } + Start-Sleep -Seconds 5 +} +if (-not $run) { throw "No workflow run found for $HeadSha within timeout." } + +Write-Progress2 "Found run $($run.databaseId), status=$($run.status), conclusion=$($run.conclusion)" + +while ($run.status -ne 'completed' -and (Get-Date) - $start -lt [TimeSpan]::FromSeconds($TimeoutSeconds)) { + Start-Sleep -Seconds $PollSeconds + $run = Get-LatestRun -HeadSha $HeadSha + $elapsed = [int]((Get-Date) - $start).TotalSeconds + Write-Progress2 "[$elapsed s] run $($run.databaseId) status=$($run.status) conclusion=$($run.conclusion)" +} + +if ($run.status -ne 'completed') { throw "Workflow did not complete within $TimeoutSeconds seconds." } +if ($run.conclusion -ne 'success') { + throw "Workflow concluded with conclusion=$($run.conclusion). Inspect with: gh run view $($run.databaseId) --repo $Repo" +} + +Write-Progress2 "Workflow succeeded. Polling PSGallery for new prerelease..." + +# Poll PSGallery until a new prerelease with a higher counter is visible +$initialMax = (Find-PSResource -Name $PackageName -Repository PSGallery -Prerelease | + Where-Object { $_.PrereleaseLabel } | + ForEach-Object { $_.PrereleaseLabel } | + Where-Object { $_ -match '(\d+)$' } | + ForEach-Object { [int]($Matches[1]) } | + Measure-Object -Maximum).Maximum + +if (-not $initialMax) { $initialMax = 0 } +Write-Progress2 "Initial max prerelease counter: $initialMax" + +$deadline = (Get-Date).AddSeconds(600) +while ((Get-Date) -lt $deadline) { + Start-Sleep -Seconds 15 + $found = Find-PSResource -Name $PackageName -Repository PSGallery -Prerelease -ErrorAction SilentlyContinue + $candidates = $found | Where-Object { + $_.Version.ToString() -like '2.2.3*' -or $_.Prerelease -match '\d+$' + } + foreach ($candidate in $candidates) { + $pre = $candidate.Prerelease + if (-not $pre -and $candidate.PrereleaseLabel) { $pre = $candidate.PrereleaseLabel } + if ($pre -match '(\d+)$') { + $counter = [int]$Matches[1] + if ($counter -gt $initialMax) { + $full = "$($candidate.Version)-$pre" + Write-Progress2 "New prerelease available: $full" + Write-Output $full + return + } + } + } + Write-Progress2 "...still waiting for new prerelease (>$initialMax)" +} + +throw "Timed out waiting for new prerelease on PSGallery." diff --git a/tools/perf/results.jsonl b/tools/perf/results.jsonl new file mode 100644 index 0000000..c22d3be --- /dev/null +++ b/tools/perf/results.jsonl @@ -0,0 +1,4 @@ +{"Timestamp":"2026-05-17T10:51:16.6038463Z","Label":"baseline (PR #45 HEAD)","Version":"2.2.3-fix44hardensodiuminterop001","Benchmarks":[{"Benchmark":"New-SodiumKeyPair","Iterations":1000,"Trials":5,"MinUs":72.066,"MedianUs":73.617,"MeanUs":74.092,"Samples":[72.066,77.693,73.617,73.111,73.975]},{"Benchmark":"New-SodiumKeyPair-Seeded","Iterations":1000,"Trials":5,"MinUs":94.256,"MedianUs":94.947,"MeanUs":95.665,"Samples":[97.119,94.256,94.947,94.848,97.156]},{"Benchmark":"Get-SodiumPublicKey","Iterations":1000,"Trials":5,"MinUs":65.358,"MedianUs":66.125,"MeanUs":66.337,"Samples":[68.092,65.358,66.125,66.364,65.746]},{"Benchmark":"ConvertTo-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":134.04,"MedianUs":135.836,"MeanUs":136.031,"Samples":[137.844,138.016,135.836,134.417,134.04]},{"Benchmark":"ConvertFrom-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":181.791,"MedianUs":196.35,"MeanUs":196.137,"Samples":[196.35,181.791,211.425,197.419,193.701]},{"Benchmark":"ColdStart-Import+OneKeyPair","Iterations":3,"MinMs":284.252,"MedianMs":287.076,"MeanMs":288.188}]} +{"Timestamp":"2026-05-17T10:51:23.4383950Z","Label":"#50 SHA256.HashData","Version":"2.2.3-fix44hardensodiuminterop002","Benchmarks":[{"Benchmark":"New-SodiumKeyPair","Iterations":1000,"Trials":5,"MinUs":71.932,"MedianUs":74.119,"MeanUs":73.395,"Samples":[71.932,74.611,74.268,74.119,72.047]},{"Benchmark":"New-SodiumKeyPair-Seeded","Iterations":1000,"Trials":5,"MinUs":85.331,"MedianUs":87.639,"MeanUs":88.073,"Samples":[91.239,87.639,86.648,85.331,89.51]},{"Benchmark":"Get-SodiumPublicKey","Iterations":1000,"Trials":5,"MinUs":62.941,"MedianUs":66.053,"MeanUs":65.837,"Samples":[62.941,65.781,68.014,66.394,66.053]},{"Benchmark":"ConvertTo-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":131.877,"MedianUs":140.558,"MeanUs":137.953,"Samples":[131.877,132.979,140.558,143.606,140.745]},{"Benchmark":"ConvertFrom-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":199.979,"MedianUs":201.739,"MeanUs":204.751,"Samples":[201.739,200.632,204.308,199.979,217.096]},{"Benchmark":"ColdStart-Import+OneKeyPair","Iterations":3,"MinMs":281.812,"MedianMs":284.842,"MeanMs":288.118}]} +{"Timestamp":"2026-05-17T11:09:59.6261002Z","Label":"#54 defer VC++ probe","Version":"2.2.3-fix44hardensodiuminterop003","Benchmarks":[{"Benchmark":"New-SodiumKeyPair","Iterations":1000,"Trials":5,"MinUs":74.658,"MedianUs":77.909,"MeanUs":79.712,"Samples":[87.03,81.516,77.909,74.658,77.447]},{"Benchmark":"New-SodiumKeyPair-Seeded","Iterations":1000,"Trials":5,"MinUs":89.76,"MedianUs":93.434,"MeanUs":95.318,"Samples":[105.217,94.988,93.193,93.434,89.76]},{"Benchmark":"Get-SodiumPublicKey","Iterations":1000,"Trials":5,"MinUs":68.895,"MedianUs":70.398,"MeanUs":73.725,"Samples":[89.486,70.502,69.344,68.895,70.398]},{"Benchmark":"ConvertTo-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":141.444,"MedianUs":146.072,"MeanUs":149.537,"Samples":[157.507,146.072,141.444,141.664,161.0]},{"Benchmark":"ConvertFrom-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":205.442,"MedianUs":219.42,"MeanUs":216.022,"Samples":[220.249,219.42,208.079,205.442,226.921]},{"Benchmark":"ColdStart-Import+OneKeyPair","Iterations":5,"MinMs":289.907,"MedianMs":300.899,"MeanMs":300.733}]} +{"Timestamp":"2026-05-17T11:15:38.7716527Z","Label":"#49 inline scalarmult","Version":"2.2.3-fix44hardensodiuminterop004","Benchmarks":[{"Benchmark":"New-SodiumKeyPair","Iterations":1000,"Trials":5,"MinUs":72.888,"MedianUs":75.747,"MeanUs":81.941,"Samples":[108.204,72.888,77.904,75.747,74.961]},{"Benchmark":"New-SodiumKeyPair-Seeded","Iterations":1000,"Trials":5,"MinUs":89.987,"MedianUs":91.972,"MeanUs":95.373,"Samples":[108.506,94.806,91.596,89.987,91.972]},{"Benchmark":"Get-SodiumPublicKey","Iterations":1000,"Trials":5,"MinUs":69.003,"MedianUs":70.168,"MeanUs":71.02,"Samples":[75.135,71.631,69.161,70.168,69.003]},{"Benchmark":"ConvertTo-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":140.063,"MedianUs":141.113,"MeanUs":146.68,"Samples":[166.444,140.063,145.466,141.113,140.313]},{"Benchmark":"ConvertFrom-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":165.066,"MedianUs":167.231,"MeanUs":167.156,"Samples":[165.969,168.561,167.231,168.953,165.066]},{"Benchmark":"ColdStart-Import+OneKeyPair","Iterations":5,"MinMs":290.639,"MedianMs":292.918,"MeanMs":293.222}]} From 0bed9b55d0aab0e5de767007926c93a33bcbb267 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 13:22:03 +0200 Subject: [PATCH 09/22] chore: untrack local perf harness scripts Removes accidentally-committed tools/perf/* and tools/Measure-Speed.ps1 (local benchmark harness, not part of the module) and gitignores them. Fixes Lint-Repository failure. --- .gitignore | 4 + tools/Measure-Speed.ps1 | 235 ------------------------------ tools/perf/Invoke-Benchmark.ps1 | 168 --------------------- tools/perf/Wait-ForPrerelease.ps1 | 94 ------------ tools/perf/results.jsonl | 4 - 5 files changed, 4 insertions(+), 501 deletions(-) delete mode 100644 tools/Measure-Speed.ps1 delete mode 100644 tools/perf/Invoke-Benchmark.ps1 delete mode 100644 tools/perf/Wait-ForPrerelease.ps1 delete mode 100644 tools/perf/results.jsonl diff --git a/.gitignore b/.gitignore index 456ca0f..c40f7d7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ outputs/* bin/ obj/ libs/ + +# Local performance harness (not part of module) +tools/perf/ +tools/Measure-Speed.ps1 diff --git a/tools/Measure-Speed.ps1 b/tools/Measure-Speed.ps1 deleted file mode 100644 index c8f57ab..0000000 --- a/tools/Measure-Speed.ps1 +++ /dev/null @@ -1,235 +0,0 @@ -[CmdletBinding()] -param( - [int] $Iterations = 1000, - [switch] $Child, - [string] $Version, - [switch] $Prerelease -) - -$ErrorActionPreference = 'Stop' - -function Ensure-SodiumVersionInstalled { - param( - [Parameter(Mandatory)] - [string] $Version, - - [Parameter(Mandatory)] - [bool] $Prerelease - ) - - $installed = Get-Module -ListAvailable -Name Sodium | - Where-Object { $_.Version.ToString() -eq $Version } - - if ($installed) { - return - } - - $installParameters = @{ - Name = 'Sodium' - Repository = 'PSGallery' - Scope = 'CurrentUser' - TrustRepository = $true - AcceptLicense = $true - SkipDependencyCheck = $true - Quiet = $true - } - - if ($Prerelease) { - $installParameters.Prerelease = $true - } else { - $installParameters.Version = $Version - } - - Install-PSResource @installParameters -} - -function Invoke-SodiumBenchmarks { - param( - [Parameter(Mandatory)] - [string] $Version, - - [Parameter(Mandatory)] - [int] $Iterations - ) - - Import-Module Sodium -RequiredVersion $Version -Force -ErrorAction Stop - - $keyPair = New-SodiumKeyPair - $message = 'The quick brown fox jumps over the lazy dog.' - $publicKey = $keyPair.PublicKey - $privateKey = $keyPair.PrivateKey - $seed = 'DeterministicSeed' - $sealedBox = ConvertTo-SodiumSealedBox -Message $message -PublicKey $publicKey - - $warmup = @( - { New-SodiumKeyPair | Out-Null }, - { New-SodiumKeyPair -Seed $seed | Out-Null }, - { Get-SodiumPublicKey -PrivateKey $privateKey | Out-Null }, - { ConvertTo-SodiumSealedBox -Message $message -PublicKey $publicKey | Out-Null }, - { ConvertFrom-SodiumSealedBox -SealedBox $sealedBox -PrivateKey $privateKey | Out-Null } - ) - - foreach ($step in $warmup) { - & $step - } - - function Measure-Loop { - param( - [Parameter(Mandatory)] - [string] $Name, - - [Parameter(Mandatory)] - [scriptblock] $Body - ) - - $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() - for ($index = 0; $index -lt $Iterations; $index++) { - & $Body | Out-Null - } - $stopwatch.Stop() - - [pscustomobject]@{ - Version = $Version - Benchmark = $Name - Iterations = $Iterations - TotalMilliseconds = [math]::Round($stopwatch.Elapsed.TotalMilliseconds, 3) - MeanMicroseconds = [math]::Round(($stopwatch.Elapsed.TotalMilliseconds * 1000) / $Iterations, 3) - } - } - - @( - Measure-Loop -Name 'New-SodiumKeyPair' -Body { New-SodiumKeyPair } - Measure-Loop -Name 'New-SodiumKeyPair-Seeded' -Body { New-SodiumKeyPair -Seed $seed } - Measure-Loop -Name 'Get-SodiumPublicKey' -Body { Get-SodiumPublicKey -PrivateKey $privateKey } - Measure-Loop -Name 'ConvertTo-SodiumSealedBox' -Body { ConvertTo-SodiumSealedBox -Message $message -PublicKey $publicKey } - Measure-Loop -Name 'ConvertFrom-SodiumSealedBox' -Body { ConvertFrom-SodiumSealedBox -SealedBox $sealedBox -PrivateKey $privateKey } - ) -} - -function Invoke-SodiumColdStartBenchmarks { - param( - [Parameter(Mandatory)] - [string] $Version, - - [Parameter(Mandatory)] - [int] $Iterations - ) - - $childScript = @' -param( - [Parameter(Mandatory)] - [string] $Version -) - -$ErrorActionPreference = ''Stop'' -Import-Module Sodium -RequiredVersion $Version -Force -ErrorAction Stop - -$keyPair = New-SodiumKeyPair -$message = ''The quick brown fox jumps over the lazy dog.'' -$publicKey = $keyPair.PublicKey -$privateKey = $keyPair.PrivateKey -$seed = ''DeterministicSeed'' -$sealedBox = ConvertTo-SodiumSealedBox -Message $message -PublicKey $publicKey - -New-SodiumKeyPair | Out-Null -New-SodiumKeyPair -Seed $seed | Out-Null -Get-SodiumPublicKey -PrivateKey $privateKey | Out-Null -ConvertTo-SodiumSealedBox -Message $message -PublicKey $publicKey | Out-Null -ConvertFrom-SodiumSealedBox -SealedBox $sealedBox -PrivateKey $privateKey | Out-Null -'@ - - $results = for ($index = 0; $index -lt $Iterations; $index++) { - $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() - & pwsh -NoProfile -Command $childScript -Version $Version | Out-Null - $stopwatch.Stop() - - [pscustomobject]@{ - Version = $Version - Benchmark = 'ColdStart-Import-CommandSuite' - Iterations = 1 - TotalMilliseconds = [math]::Round($stopwatch.Elapsed.TotalMilliseconds, 3) - MeanMicroseconds = [math]::Round($stopwatch.Elapsed.TotalMilliseconds * 1000, 3) - } - } - - $results -} - -if ($Child) { - Invoke-SodiumBenchmarks -Version $Version -Iterations $Iterations | ConvertTo-Json -Depth 4 - return -} - -$targets = @( - [pscustomobject]@{ - Label = 'stable' - Version = '2.2.2' - Prerelease = $false - } - [pscustomobject]@{ - Label = 'preview' - Version = '2.2.3' - Prerelease = $true - } -) - -foreach ($target in $targets) { - Ensure-SodiumVersionInstalled -Version $target.Version -Prerelease $target.Prerelease -} - -$results = foreach ($target in $targets) { - $childOutput = & pwsh -NoProfile -File $PSCommandPath -Child -Version $target.Version -Iterations $Iterations - $childOutput | ConvertFrom-Json -} - -$coldStartResults = foreach ($target in $targets) { - Invoke-SodiumColdStartBenchmarks -Version $target.Version -Iterations $Iterations -} - -$results | - Sort-Object Benchmark, Version | - Select-Object Version, Benchmark, Iterations, TotalMilliseconds, MeanMicroseconds | - Format-Table -AutoSize - -$coldStartResults | - Group-Object Version | - ForEach-Object { - $groupAverage = [math]::Round(($_.Group | Measure-Object -Property TotalMilliseconds -Average).Average, 3) - [pscustomobject]@{ - Version = $_.Name - Benchmark = 'ColdStart-Import-CommandSuite' - Iterations = $_.Count - AverageMilliseconds = $groupAverage - AverageMicroseconds = [math]::Round($groupAverage * 1000, 3) - } - } | - Sort-Object Version | - Format-Table -AutoSize - -$comparison = $results | - Group-Object Benchmark | - ForEach-Object { - $stable = $_.Group | Where-Object { $_.Version -eq '2.2.2' } - $preview = $_.Group | Where-Object { $_.Version -eq '2.2.3' } - - [pscustomobject]@{ - Benchmark = $_.Name - StableMs = $stable.TotalMilliseconds - PreviewMs = $preview.TotalMilliseconds - DeltaMs = [math]::Round(($stable.TotalMilliseconds - $preview.TotalMilliseconds), 3) - Percent = [math]::Round((($stable.TotalMilliseconds - $preview.TotalMilliseconds) / $stable.TotalMilliseconds) * 100, 2) - } - } - -$comparison | Sort-Object Benchmark | Format-Table -AutoSize - -$coldComparison = $coldStartResults | - Group-Object Version | - ForEach-Object { - [pscustomobject]@{ - Version = $_.Name - AverageMilliseconds = [math]::Round(($_.Group | Measure-Object -Property TotalMilliseconds -Average).Average, 3) - } - } - -$coldComparison | Sort-Object Version | Format-Table -AutoSize \ No newline at end of file diff --git a/tools/perf/Invoke-Benchmark.ps1 b/tools/perf/Invoke-Benchmark.ps1 deleted file mode 100644 index ef29df7..0000000 --- a/tools/perf/Invoke-Benchmark.ps1 +++ /dev/null @@ -1,168 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)] - [string] $Version, - - [Parameter(Mandatory)] - [string] $Label, - - [Parameter()] - [int] $Iterations = 1000, - - [Parameter()] - [int] $Trials = 5, - - [Parameter()] - [int] $ColdStartIterations = 3, - - [Parameter()] - [string] $ResultsPath = (Join-Path $PSScriptRoot 'results.jsonl') -) - -$ErrorActionPreference = 'Stop' - -function Install-IfMissing { - param([string] $Version) - - $base = ($Version -split '-',2)[0] - $pre = if ($Version.Contains('-')) { ($Version -split '-',2)[1] } else { '' } - - $installed = Get-Module -ListAvailable -Name Sodium | Where-Object { - $_.Version.ToString() -eq $base -and ($_.PrivateData.PSData.Prerelease -as [string]) -eq $pre - } - if ($installed) { return } - - $params = @{ - Name = 'Sodium' - Repository = 'PSGallery' - Version = $Version - Scope = 'CurrentUser' - TrustRepository = $true - AcceptLicense = $true - Prerelease = $true - Reinstall = $true - } - [Console]::Error.WriteLine("Installing Sodium $Version from PSGallery...") - Install-PSResource @params -} - -function Run-WarmTrial { - param([string] $Version, [int] $Iterations) - - $script = @" -param([string] `$Version, [int] `$Iterations) -`$ErrorActionPreference = 'Stop' -`$base = (`$Version -split '-',2)[0] -Import-Module Sodium -RequiredVersion `$base -Force -ErrorAction Stop - -`$keyPair = New-SodiumKeyPair -`$message = 'The quick brown fox jumps over the lazy dog.' -`$publicKey = `$keyPair.PublicKey -`$privateKey = `$keyPair.PrivateKey -`$seed = 'DeterministicSeed' -`$sealedBox = ConvertTo-SodiumSealedBox -Message `$message -PublicKey `$publicKey - -1..10 | ForEach-Object { - `$null = New-SodiumKeyPair - `$null = New-SodiumKeyPair -Seed `$seed - `$null = Get-SodiumPublicKey -PrivateKey `$privateKey - `$null = ConvertTo-SodiumSealedBox -Message `$message -PublicKey `$publicKey - `$null = ConvertFrom-SodiumSealedBox -SealedBox `$sealedBox -PrivateKey `$privateKey -} - -function Measure-Loop { - param([string] `$Name, [scriptblock] `$Body, [int] `$Iterations) - [System.GC]::Collect(); [System.GC]::WaitForPendingFinalizers(); [System.GC]::Collect() - `$sw = [System.Diagnostics.Stopwatch]::StartNew() - for (`$i = 0; `$i -lt `$Iterations; `$i++) { `$null = & `$Body } - `$sw.Stop() - [pscustomobject]@{ - Benchmark = `$Name - Iterations = `$Iterations - TotalMs = [math]::Round(`$sw.Elapsed.TotalMilliseconds, 3) - MeanUs = [math]::Round((`$sw.Elapsed.TotalMilliseconds * 1000) / `$Iterations, 3) - } -} - -@( - Measure-Loop 'New-SodiumKeyPair' { New-SodiumKeyPair } `$Iterations - Measure-Loop 'New-SodiumKeyPair-Seeded' { New-SodiumKeyPair -Seed `$seed } `$Iterations - Measure-Loop 'Get-SodiumPublicKey' { Get-SodiumPublicKey -PrivateKey `$privateKey } `$Iterations - Measure-Loop 'ConvertTo-SodiumSealedBox' { ConvertTo-SodiumSealedBox -Message `$message -PublicKey `$publicKey } `$Iterations - Measure-Loop 'ConvertFrom-SodiumSealedBox' { ConvertFrom-SodiumSealedBox -SealedBox `$sealedBox -PrivateKey `$privateKey } `$Iterations -) | ConvertTo-Json -Depth 4 -"@ - - $tmp = [System.IO.Path]::GetTempFileName() + '.ps1' - Set-Content -LiteralPath $tmp -Value $script -Encoding UTF8 - try { - $output = & pwsh -NoProfile -File $tmp -Version $Version -Iterations $Iterations 2>&1 - if ($LASTEXITCODE -ne 0) { throw ("Child pwsh failed: " + ($output -join "`n")) } - $json = ($output | Where-Object { $_ -is [string] }) -join "`n" - return ($json | ConvertFrom-Json) - } finally { - Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue - } -} - -function Run-ColdStartBenchmark { - param([string] $Version, [int] $Iterations) - - $base = ($Version -split '-',2)[0] - $totals = for ($i = 0; $i -lt $Iterations; $i++) { - $sw = [System.Diagnostics.Stopwatch]::StartNew() - & pwsh -NoProfile -Command "Import-Module Sodium -RequiredVersion $base -Force; `$null = New-SodiumKeyPair" | Out-Null - $sw.Stop() - $sw.Elapsed.TotalMilliseconds - } - $sorted = $totals | Sort-Object - [pscustomobject]@{ - Benchmark = 'ColdStart-Import+OneKeyPair' - Iterations = $Iterations - MinMs = [math]::Round((($totals | Measure-Object -Minimum).Minimum), 3) - MedianMs = [math]::Round($sorted[[math]::Floor($sorted.Count/2)], 3) - MeanMs = [math]::Round((($totals | Measure-Object -Average).Average), 3) - } -} - -Install-IfMissing -Version $Version - -[Console]::Error.WriteLine("Running $Trials trials of $Iterations iterations for $Version [$Label]...") -$trialResults = [System.Collections.Generic.List[object]]::new() -for ($t = 1; $t -le $Trials; $t++) { - [Console]::Error.WriteLine(" trial $t/$Trials") - $trialResults.Add((Run-WarmTrial -Version $Version -Iterations $Iterations)) -} - -$benchmarkNames = $trialResults[0] | ForEach-Object { $_.Benchmark } -$aggregated = foreach ($name in $benchmarkNames) { - $samples = foreach ($trial in $trialResults) { ($trial | Where-Object Benchmark -EQ $name).MeanUs } - $sorted = @($samples | Sort-Object) - [pscustomobject]@{ - Benchmark = $name - Iterations = $Iterations - Trials = $Trials - MinUs = [math]::Round((($sorted | Measure-Object -Minimum).Minimum), 3) - MedianUs = [math]::Round($sorted[[math]::Floor($sorted.Count/2)], 3) - MeanUs = [math]::Round((($sorted | Measure-Object -Average).Average), 3) - Samples = $samples - } -} - -[Console]::Error.WriteLine("Running cold-start benchmark (iterations=$ColdStartIterations) for $Version [$Label]...") -$cold = Run-ColdStartBenchmark -Version $Version -Iterations $ColdStartIterations - -$record = [pscustomobject]@{ - Timestamp = (Get-Date).ToUniversalTime().ToString('o') - Label = $Label - Version = $Version - Benchmarks = @($aggregated) + @($cold) -} - -$record.Benchmarks | - Select-Object Benchmark, Iterations, Trials, MinUs, MedianUs, MeanUs, MinMs, MedianMs, MeanMs | - Format-Table -AutoSize | Out-String | Write-Host - -$json = $record | ConvertTo-Json -Depth 6 -Compress -Add-Content -LiteralPath $ResultsPath -Value $json -[Console]::Error.WriteLine("Appended to $ResultsPath") diff --git a/tools/perf/Wait-ForPrerelease.ps1 b/tools/perf/Wait-ForPrerelease.ps1 deleted file mode 100644 index 453d72c..0000000 --- a/tools/perf/Wait-ForPrerelease.ps1 +++ /dev/null @@ -1,94 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)] - [string] $HeadSha, - - [Parameter()] - [string] $Repo = 'PSModule/Sodium', - - [Parameter()] - [string] $WorkflowName = 'Process-PSModule', - - [Parameter()] - [int] $TimeoutSeconds = 1500, - - [Parameter()] - [int] $PollSeconds = 20, - - [Parameter()] - [string] $PackageName = 'Sodium' -) - -$ErrorActionPreference = 'Stop' - -function Write-Progress2 { param([string] $Message) [Console]::Error.WriteLine($Message) } - -function Get-LatestRun { - param([string] $HeadSha) - $json = gh run list --repo $Repo --branch fix/44-harden-sodium-interop --limit 20 --json status,conclusion,name,headSha,databaseId,createdAt - if ($LASTEXITCODE -ne 0) { throw "gh run list failed" } - $runs = $json | ConvertFrom-Json - $runs | Where-Object { $_.headSha -eq $HeadSha -and $_.name -eq $WorkflowName } | Sort-Object createdAt -Descending | Select-Object -First 1 -} - -$start = Get-Date -Write-Progress2 "Waiting for workflow run on $HeadSha..." -$run = $null -while ((Get-Date) - $start -lt [TimeSpan]::FromSeconds($TimeoutSeconds)) { - $run = Get-LatestRun -HeadSha $HeadSha - if ($run) { break } - Start-Sleep -Seconds 5 -} -if (-not $run) { throw "No workflow run found for $HeadSha within timeout." } - -Write-Progress2 "Found run $($run.databaseId), status=$($run.status), conclusion=$($run.conclusion)" - -while ($run.status -ne 'completed' -and (Get-Date) - $start -lt [TimeSpan]::FromSeconds($TimeoutSeconds)) { - Start-Sleep -Seconds $PollSeconds - $run = Get-LatestRun -HeadSha $HeadSha - $elapsed = [int]((Get-Date) - $start).TotalSeconds - Write-Progress2 "[$elapsed s] run $($run.databaseId) status=$($run.status) conclusion=$($run.conclusion)" -} - -if ($run.status -ne 'completed') { throw "Workflow did not complete within $TimeoutSeconds seconds." } -if ($run.conclusion -ne 'success') { - throw "Workflow concluded with conclusion=$($run.conclusion). Inspect with: gh run view $($run.databaseId) --repo $Repo" -} - -Write-Progress2 "Workflow succeeded. Polling PSGallery for new prerelease..." - -# Poll PSGallery until a new prerelease with a higher counter is visible -$initialMax = (Find-PSResource -Name $PackageName -Repository PSGallery -Prerelease | - Where-Object { $_.PrereleaseLabel } | - ForEach-Object { $_.PrereleaseLabel } | - Where-Object { $_ -match '(\d+)$' } | - ForEach-Object { [int]($Matches[1]) } | - Measure-Object -Maximum).Maximum - -if (-not $initialMax) { $initialMax = 0 } -Write-Progress2 "Initial max prerelease counter: $initialMax" - -$deadline = (Get-Date).AddSeconds(600) -while ((Get-Date) -lt $deadline) { - Start-Sleep -Seconds 15 - $found = Find-PSResource -Name $PackageName -Repository PSGallery -Prerelease -ErrorAction SilentlyContinue - $candidates = $found | Where-Object { - $_.Version.ToString() -like '2.2.3*' -or $_.Prerelease -match '\d+$' - } - foreach ($candidate in $candidates) { - $pre = $candidate.Prerelease - if (-not $pre -and $candidate.PrereleaseLabel) { $pre = $candidate.PrereleaseLabel } - if ($pre -match '(\d+)$') { - $counter = [int]$Matches[1] - if ($counter -gt $initialMax) { - $full = "$($candidate.Version)-$pre" - Write-Progress2 "New prerelease available: $full" - Write-Output $full - return - } - } - } - Write-Progress2 "...still waiting for new prerelease (>$initialMax)" -} - -throw "Timed out waiting for new prerelease on PSGallery." diff --git a/tools/perf/results.jsonl b/tools/perf/results.jsonl deleted file mode 100644 index c22d3be..0000000 --- a/tools/perf/results.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"Timestamp":"2026-05-17T10:51:16.6038463Z","Label":"baseline (PR #45 HEAD)","Version":"2.2.3-fix44hardensodiuminterop001","Benchmarks":[{"Benchmark":"New-SodiumKeyPair","Iterations":1000,"Trials":5,"MinUs":72.066,"MedianUs":73.617,"MeanUs":74.092,"Samples":[72.066,77.693,73.617,73.111,73.975]},{"Benchmark":"New-SodiumKeyPair-Seeded","Iterations":1000,"Trials":5,"MinUs":94.256,"MedianUs":94.947,"MeanUs":95.665,"Samples":[97.119,94.256,94.947,94.848,97.156]},{"Benchmark":"Get-SodiumPublicKey","Iterations":1000,"Trials":5,"MinUs":65.358,"MedianUs":66.125,"MeanUs":66.337,"Samples":[68.092,65.358,66.125,66.364,65.746]},{"Benchmark":"ConvertTo-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":134.04,"MedianUs":135.836,"MeanUs":136.031,"Samples":[137.844,138.016,135.836,134.417,134.04]},{"Benchmark":"ConvertFrom-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":181.791,"MedianUs":196.35,"MeanUs":196.137,"Samples":[196.35,181.791,211.425,197.419,193.701]},{"Benchmark":"ColdStart-Import+OneKeyPair","Iterations":3,"MinMs":284.252,"MedianMs":287.076,"MeanMs":288.188}]} -{"Timestamp":"2026-05-17T10:51:23.4383950Z","Label":"#50 SHA256.HashData","Version":"2.2.3-fix44hardensodiuminterop002","Benchmarks":[{"Benchmark":"New-SodiumKeyPair","Iterations":1000,"Trials":5,"MinUs":71.932,"MedianUs":74.119,"MeanUs":73.395,"Samples":[71.932,74.611,74.268,74.119,72.047]},{"Benchmark":"New-SodiumKeyPair-Seeded","Iterations":1000,"Trials":5,"MinUs":85.331,"MedianUs":87.639,"MeanUs":88.073,"Samples":[91.239,87.639,86.648,85.331,89.51]},{"Benchmark":"Get-SodiumPublicKey","Iterations":1000,"Trials":5,"MinUs":62.941,"MedianUs":66.053,"MeanUs":65.837,"Samples":[62.941,65.781,68.014,66.394,66.053]},{"Benchmark":"ConvertTo-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":131.877,"MedianUs":140.558,"MeanUs":137.953,"Samples":[131.877,132.979,140.558,143.606,140.745]},{"Benchmark":"ConvertFrom-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":199.979,"MedianUs":201.739,"MeanUs":204.751,"Samples":[201.739,200.632,204.308,199.979,217.096]},{"Benchmark":"ColdStart-Import+OneKeyPair","Iterations":3,"MinMs":281.812,"MedianMs":284.842,"MeanMs":288.118}]} -{"Timestamp":"2026-05-17T11:09:59.6261002Z","Label":"#54 defer VC++ probe","Version":"2.2.3-fix44hardensodiuminterop003","Benchmarks":[{"Benchmark":"New-SodiumKeyPair","Iterations":1000,"Trials":5,"MinUs":74.658,"MedianUs":77.909,"MeanUs":79.712,"Samples":[87.03,81.516,77.909,74.658,77.447]},{"Benchmark":"New-SodiumKeyPair-Seeded","Iterations":1000,"Trials":5,"MinUs":89.76,"MedianUs":93.434,"MeanUs":95.318,"Samples":[105.217,94.988,93.193,93.434,89.76]},{"Benchmark":"Get-SodiumPublicKey","Iterations":1000,"Trials":5,"MinUs":68.895,"MedianUs":70.398,"MeanUs":73.725,"Samples":[89.486,70.502,69.344,68.895,70.398]},{"Benchmark":"ConvertTo-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":141.444,"MedianUs":146.072,"MeanUs":149.537,"Samples":[157.507,146.072,141.444,141.664,161.0]},{"Benchmark":"ConvertFrom-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":205.442,"MedianUs":219.42,"MeanUs":216.022,"Samples":[220.249,219.42,208.079,205.442,226.921]},{"Benchmark":"ColdStart-Import+OneKeyPair","Iterations":5,"MinMs":289.907,"MedianMs":300.899,"MeanMs":300.733}]} -{"Timestamp":"2026-05-17T11:15:38.7716527Z","Label":"#49 inline scalarmult","Version":"2.2.3-fix44hardensodiuminterop004","Benchmarks":[{"Benchmark":"New-SodiumKeyPair","Iterations":1000,"Trials":5,"MinUs":72.888,"MedianUs":75.747,"MeanUs":81.941,"Samples":[108.204,72.888,77.904,75.747,74.961]},{"Benchmark":"New-SodiumKeyPair-Seeded","Iterations":1000,"Trials":5,"MinUs":89.987,"MedianUs":91.972,"MeanUs":95.373,"Samples":[108.506,94.806,91.596,89.987,91.972]},{"Benchmark":"Get-SodiumPublicKey","Iterations":1000,"Trials":5,"MinUs":69.003,"MedianUs":70.168,"MeanUs":71.02,"Samples":[75.135,71.631,69.161,70.168,69.003]},{"Benchmark":"ConvertTo-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":140.063,"MedianUs":141.113,"MeanUs":146.68,"Samples":[166.444,140.063,145.466,141.113,140.313]},{"Benchmark":"ConvertFrom-SodiumSealedBox","Iterations":1000,"Trials":5,"MinUs":165.066,"MedianUs":167.231,"MeanUs":167.156,"Samples":[165.969,168.561,167.231,168.953,165.066]},{"Benchmark":"ColdStart-Import+OneKeyPair","Iterations":5,"MinMs":290.639,"MedianMs":292.918,"MeanMs":293.222}]} From 203971d397d31343226e1f1b142573177d0562bc Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 13:28:59 +0200 Subject: [PATCH 10/22] perf(#51): cache libsodium constant sizes in C# and validate seed against crypto_box_seedbytes Caches crypto_box_publickeybytes/secretkeybytes/sealbytes/seedbytes as static readonly fields populated on type initialization. Every cmdlet call previously made 2-3 P/Invoke calls just for buffer-length validation; now zero. Also adds the missing crypto_box_seedbytes binding and uses it (instead of crypto_box_secretkeybytes, per PR review feedback) to validate seed length. The two values happen to be equal for curve25519 today, but tying the check to the correct constant makes the intent explicit. Refs #51. --- PSModule/Sodium/Sodium.cs | 45 ++++++++++++++------- src/functions/private/Initialize-Sodium.ps1 | 1 + 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/PSModule/Sodium/Sodium.cs b/PSModule/Sodium/Sodium.cs index 238d3dc..37c53f1 100644 --- a/PSModule/Sodium/Sodium.cs +++ b/PSModule/Sodium/Sodium.cs @@ -39,11 +39,21 @@ private static class Native [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] public static extern UIntPtr crypto_box_sealbytes(); + [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + public static extern UIntPtr crypto_box_seedbytes(); + [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] public static extern int crypto_scalarmult_base(byte[] publicKey, byte[] privateKey); } + // libsodium guarantees these *_bytes() functions return constants and are safe to call without sodium_init(). + private static readonly int PublicKeyBytes = GetRequiredLength(Native.crypto_box_publickeybytes()); + private static readonly int SecretKeyBytes = GetRequiredLength(Native.crypto_box_secretkeybytes()); + private static readonly int SealBytes = GetRequiredLength(Native.crypto_box_sealbytes()); + private static readonly int SeedBytes = GetRequiredLength(Native.crypto_box_seedbytes()); + public static int sodium_init() { return Native.sodium_init(); @@ -51,17 +61,17 @@ public static int sodium_init() public static int crypto_box_keypair(byte[] publicKey, byte[] privateKey) { - ValidateMinimumBufferLength(publicKey, GetRequiredLength(crypto_box_publickeybytes()), nameof(publicKey)); - ValidateMinimumBufferLength(privateKey, GetRequiredLength(crypto_box_secretkeybytes()), nameof(privateKey)); + ValidateMinimumBufferLength(publicKey, PublicKeyBytes, nameof(publicKey)); + ValidateMinimumBufferLength(privateKey, SecretKeyBytes, nameof(privateKey)); return Native.crypto_box_keypair(publicKey, privateKey); } public static int crypto_box_seed_keypair(byte[] publicKey, byte[] privateKey, byte[] seed) { - ValidateMinimumBufferLength(publicKey, GetRequiredLength(crypto_box_publickeybytes()), nameof(publicKey)); - ValidateMinimumBufferLength(privateKey, GetRequiredLength(crypto_box_secretkeybytes()), nameof(privateKey)); - ValidateExactBufferLength(seed, GetRequiredLength(crypto_box_secretkeybytes()), nameof(seed)); + ValidateMinimumBufferLength(publicKey, PublicKeyBytes, nameof(publicKey)); + ValidateMinimumBufferLength(privateKey, SecretKeyBytes, nameof(privateKey)); + ValidateExactBufferLength(seed, SeedBytes, nameof(seed)); return Native.crypto_box_seed_keypair(publicKey, privateKey, seed); } @@ -69,15 +79,15 @@ public static int crypto_box_seed_keypair(byte[] publicKey, byte[] privateKey, b public static int crypto_box_seal(byte[] ciphertext, byte[] message, ulong mlen, byte[] publicKey) { ValidateMinimumBufferLength(message, mlen, nameof(message)); - ValidateMinimumBufferLength(ciphertext, checked(mlen + crypto_box_sealbytes().ToUInt64()), nameof(ciphertext)); - ValidateExactBufferLength(publicKey, GetRequiredLength(crypto_box_publickeybytes()), nameof(publicKey)); + ValidateMinimumBufferLength(ciphertext, checked(mlen + (ulong)SealBytes), nameof(ciphertext)); + ValidateExactBufferLength(publicKey, PublicKeyBytes, nameof(publicKey)); return Native.crypto_box_seal(ciphertext, message, mlen, publicKey); } public static int crypto_box_seal_open(byte[] decrypted, byte[] ciphertext, ulong ciphertextLength, byte[] publicKey, byte[] privateKey) { - var sealBytes = crypto_box_sealbytes().ToUInt64(); + var sealBytes = (ulong)SealBytes; if (ciphertextLength < sealBytes) { throw new ArgumentException($"The ciphertext must be at least {sealBytes} bytes.", nameof(ciphertext)); @@ -85,31 +95,36 @@ public static int crypto_box_seal_open(byte[] decrypted, byte[] ciphertext, ulon ValidateMinimumBufferLength(ciphertext, ciphertextLength, nameof(ciphertext)); ValidateMinimumBufferLength(decrypted, ciphertextLength - sealBytes, nameof(decrypted)); - ValidateExactBufferLength(publicKey, GetRequiredLength(crypto_box_publickeybytes()), nameof(publicKey)); - ValidateExactBufferLength(privateKey, GetRequiredLength(crypto_box_secretkeybytes()), nameof(privateKey)); + ValidateExactBufferLength(publicKey, PublicKeyBytes, nameof(publicKey)); + ValidateExactBufferLength(privateKey, SecretKeyBytes, nameof(privateKey)); return Native.crypto_box_seal_open(decrypted, ciphertext, ciphertextLength, publicKey, privateKey); } public static UIntPtr crypto_box_publickeybytes() { - return Native.crypto_box_publickeybytes(); + return (UIntPtr)PublicKeyBytes; } public static UIntPtr crypto_box_secretkeybytes() { - return Native.crypto_box_secretkeybytes(); + return (UIntPtr)SecretKeyBytes; } public static UIntPtr crypto_box_sealbytes() { - return Native.crypto_box_sealbytes(); + return (UIntPtr)SealBytes; + } + + public static UIntPtr crypto_box_seedbytes() + { + return (UIntPtr)SeedBytes; } public static int crypto_scalarmult_base(byte[] publicKey, byte[] privateKey) { - ValidateMinimumBufferLength(publicKey, GetRequiredLength(crypto_box_publickeybytes()), nameof(publicKey)); - ValidateExactBufferLength(privateKey, GetRequiredLength(crypto_box_secretkeybytes()), nameof(privateKey)); + ValidateMinimumBufferLength(publicKey, PublicKeyBytes, nameof(publicKey)); + ValidateExactBufferLength(privateKey, SecretKeyBytes, nameof(privateKey)); return Native.crypto_scalarmult_base(publicKey, privateKey); } diff --git a/src/functions/private/Initialize-Sodium.ps1 b/src/functions/private/Initialize-Sodium.ps1 index 7c02751..85502e0 100644 --- a/src/functions/private/Initialize-Sodium.ps1 +++ b/src/functions/private/Initialize-Sodium.ps1 @@ -34,6 +34,7 @@ $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() $script:SodiumInitialized = $true } From 9963c235beb514a4340acdf527e8cc908c75a68e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 13:34:52 +0200 Subject: [PATCH 11/22] perf(#48): switch native interop to LibraryImport source generator Replaces [DllImport] declarations with [LibraryImport], which generates marshalling code at compile time instead of relying on the runtime ILStub generator. Eliminates per-call stub setup and is AOT-compatible. Enables AllowUnsafeBlocks (required by the LibraryImport generator). Tags [Out] on output buffers so the generator skips the redundant copy-back. Refs #48. --- PSModule/Sodium.csproj | 1 + PSModule/Sodium/Sodium.cs | 54 +++++++++++++++++++++++---------------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/PSModule/Sodium.csproj b/PSModule/Sodium.csproj index 9bfec7c..2a952dd 100644 --- a/PSModule/Sodium.csproj +++ b/PSModule/Sodium.csproj @@ -3,6 +3,7 @@ net8.0 PSModule.Sodium + true diff --git a/PSModule/Sodium/Sodium.cs b/PSModule/Sodium/Sodium.cs index 37c53f1..19c88b1 100644 --- a/PSModule/Sodium/Sodium.cs +++ b/PSModule/Sodium/Sodium.cs @@ -3,49 +3,59 @@ namespace PSModule { - public static class Sodium + public static partial class Sodium { - private static class Native + private static partial class Native { - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [LibraryImport("libsodium")] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] - public static extern int sodium_init(); + [UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + public static partial int sodium_init(); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [LibraryImport("libsodium")] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] - public static extern int crypto_box_keypair(byte[] publicKey, byte[] privateKey); + [UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + public static partial int crypto_box_keypair([Out] byte[] publicKey, [Out] byte[] privateKey); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [LibraryImport("libsodium")] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] - public static extern int crypto_box_seed_keypair(byte[] publicKey, byte[] privateKey, byte[] seed); + [UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + public static partial int crypto_box_seed_keypair([Out] byte[] publicKey, [Out] byte[] privateKey, byte[] seed); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [LibraryImport("libsodium")] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] - public static extern int crypto_box_seal(byte[] ciphertext, byte[] message, ulong mlen, byte[] publicKey); + [UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + public static partial int crypto_box_seal([Out] byte[] ciphertext, byte[] message, ulong mlen, byte[] publicKey); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [LibraryImport("libsodium")] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] - public static extern int crypto_box_seal_open(byte[] decrypted, byte[] ciphertext, ulong ciphertextLength, byte[] publicKey, byte[] privateKey); + [UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + public static partial int crypto_box_seal_open([Out] byte[] decrypted, byte[] ciphertext, ulong ciphertextLength, byte[] publicKey, byte[] privateKey); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [LibraryImport("libsodium")] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] - public static extern UIntPtr crypto_box_publickeybytes(); + [UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + public static partial UIntPtr crypto_box_publickeybytes(); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [LibraryImport("libsodium")] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] - public static extern UIntPtr crypto_box_secretkeybytes(); + [UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + public static partial UIntPtr crypto_box_secretkeybytes(); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [LibraryImport("libsodium")] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] - public static extern UIntPtr crypto_box_sealbytes(); + [UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + public static partial UIntPtr crypto_box_sealbytes(); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [LibraryImport("libsodium")] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] - public static extern UIntPtr crypto_box_seedbytes(); + [UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + public static partial UIntPtr crypto_box_seedbytes(); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] + [LibraryImport("libsodium")] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] - public static extern int crypto_scalarmult_base(byte[] publicKey, byte[] privateKey); + [UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + public static partial int crypto_scalarmult_base([Out] byte[] publicKey, byte[] privateKey); } // libsodium guarantees these *_bytes() functions return constants and are safe to call without sodium_init(). From 25ac3a44234b55a7d3ba91560f77a03bc5129050 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 13:42:54 +0200 Subject: [PATCH 12/22] perf(#52): delegate cmdlets to base64-centric C# APIs Adds high-level SealBase64 / OpenSealBase64 / GenerateKeyPairBase64 / DerivePublicKeyBase64 entrypoints to PSModule.Sodium that perform base64/UTF-8 encoding and the native libsodium call in a single managed transition. The PowerShell cmdlets become thin wrappers, removing 4-6 .NET method invocations from the hot path per call. Refs #52. --- PSModule/Sodium/Sodium.cs | 157 ++++++++++++++++++ .../public/ConvertFrom-SodiumSealedBox.ps1 | 47 +----- .../public/ConvertTo-SodiumSealedBox.ps1 | 24 +-- src/functions/public/Get-SodiumPublicKey.ps1 | 35 ++-- src/functions/public/New-SodiumKeyPair.ps1 | 43 +---- 5 files changed, 185 insertions(+), 121 deletions(-) diff --git a/PSModule/Sodium/Sodium.cs b/PSModule/Sodium/Sodium.cs index 19c88b1..218e890 100644 --- a/PSModule/Sodium/Sodium.cs +++ b/PSModule/Sodium/Sodium.cs @@ -1,5 +1,7 @@ using System; using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; namespace PSModule { @@ -139,6 +141,161 @@ public static int crypto_scalarmult_base(byte[] publicKey, byte[] privateKey) return Native.crypto_scalarmult_base(publicKey, privateKey); } + // ---------- Base64-centric high-level API (see issue #52) ---------- + // These helpers do base64/UTF-8 encoding and native interop in a single managed call, + // avoiding the overhead of multiple PowerShell-level method invocations on the hot path. + + public sealed class KeyPairBase64 + { + public string PublicKey { get; } + public string PrivateKey { get; } + + internal KeyPairBase64(string publicKey, string privateKey) + { + PublicKey = publicKey; + PrivateKey = privateKey; + } + } + + public static KeyPairBase64 GenerateKeyPairBase64() + { + var publicKey = new byte[PublicKeyBytes]; + var privateKey = new byte[SecretKeyBytes]; + try + { + if (Native.crypto_box_keypair(publicKey, privateKey) != 0) + { + throw new InvalidOperationException("Key pair generation failed."); + } + return new KeyPairBase64(Convert.ToBase64String(publicKey), Convert.ToBase64String(privateKey)); + } + finally + { + CryptographicOperations.ZeroMemory(privateKey); + } + } + + public static KeyPairBase64 GenerateKeyPairBase64(string seedText) + { + ArgumentNullException.ThrowIfNull(seedText); + var publicKey = new byte[PublicKeyBytes]; + var privateKey = new byte[SecretKeyBytes]; + var seedSource = Encoding.UTF8.GetBytes(seedText); + var seed = new byte[SeedBytes]; + try + { + if (!SHA256.TryHashData(seedSource, seed, out var written) || written != SeedBytes) + { + throw new InvalidOperationException("Failed to derive seed bytes from input."); + } + if (Native.crypto_box_seed_keypair(publicKey, privateKey, seed) != 0) + { + throw new InvalidOperationException("Seeded key pair generation failed."); + } + return new KeyPairBase64(Convert.ToBase64String(publicKey), Convert.ToBase64String(privateKey)); + } + finally + { + CryptographicOperations.ZeroMemory(privateKey); + CryptographicOperations.ZeroMemory(seed); + CryptographicOperations.ZeroMemory(seedSource); + } + } + + public static string DerivePublicKeyBase64(string privateKeyBase64) + { + ArgumentNullException.ThrowIfNull(privateKeyBase64); + var privateKey = DecodeBase64Exact(privateKeyBase64, SecretKeyBytes, nameof(privateKeyBase64)); + var publicKey = new byte[PublicKeyBytes]; + try + { + if (Native.crypto_scalarmult_base(publicKey, privateKey) != 0) + { + throw new InvalidOperationException("Unable to derive public key from private key."); + } + return Convert.ToBase64String(publicKey); + } + finally + { + CryptographicOperations.ZeroMemory(privateKey); + } + } + + public static string SealBase64(string plaintext, string publicKeyBase64) + { + ArgumentNullException.ThrowIfNull(plaintext); + ArgumentNullException.ThrowIfNull(publicKeyBase64); + var publicKey = DecodeBase64Exact(publicKeyBase64, PublicKeyBytes, nameof(publicKeyBase64)); + var message = Encoding.UTF8.GetBytes(plaintext); + var ciphertext = new byte[message.Length + SealBytes]; + if (Native.crypto_box_seal(ciphertext, message, (ulong)message.LongLength, publicKey) != 0) + { + throw new InvalidOperationException("Encryption failed."); + } + return Convert.ToBase64String(ciphertext); + } + + public static string OpenSealBase64(string ciphertextBase64, string privateKeyBase64) + { + return OpenSealBase64Core(ciphertextBase64, privateKeyBase64, publicKeyBase64: null); + } + + public static string OpenSealBase64(string ciphertextBase64, string privateKeyBase64, string publicKeyBase64) + { + return OpenSealBase64Core(ciphertextBase64, privateKeyBase64, publicKeyBase64); + } + + private static string OpenSealBase64Core(string ciphertextBase64, string privateKeyBase64, string publicKeyBase64) + { + ArgumentNullException.ThrowIfNull(ciphertextBase64); + ArgumentNullException.ThrowIfNull(privateKeyBase64); + + var ciphertext = Convert.FromBase64String(ciphertextBase64); + if (ciphertext.Length < SealBytes) + { + throw new ArgumentException($"Invalid sealed box. Expected at least {SealBytes} bytes but got {ciphertext.Length}.", nameof(ciphertextBase64)); + } + var privateKey = DecodeBase64Exact(privateKeyBase64, SecretKeyBytes, nameof(privateKeyBase64)); + var publicKey = new byte[PublicKeyBytes]; + var decrypted = new byte[ciphertext.Length - SealBytes]; + try + { + if (string.IsNullOrEmpty(publicKeyBase64)) + { + if (Native.crypto_scalarmult_base(publicKey, privateKey) != 0) + { + throw new InvalidOperationException("Unable to derive public key from private key."); + } + } + else + { + var providedPk = DecodeBase64Exact(publicKeyBase64, PublicKeyBytes, nameof(publicKeyBase64)); + Buffer.BlockCopy(providedPk, 0, publicKey, 0, PublicKeyBytes); + } + + if (Native.crypto_box_seal_open(decrypted, ciphertext, (ulong)ciphertext.LongLength, publicKey, privateKey) != 0) + { + throw new InvalidOperationException("Decryption failed."); + } + return Encoding.UTF8.GetString(decrypted); + } + finally + { + CryptographicOperations.ZeroMemory(privateKey); + CryptographicOperations.ZeroMemory(decrypted); + } + } + + private static byte[] DecodeBase64Exact(string value, int expectedLength, string parameterName) + { + var bytes = Convert.FromBase64String(value); + if (bytes.Length != expectedLength) + { + throw new ArgumentException($"Invalid base64 value. Expected {expectedLength} bytes but got {bytes.Length}.", parameterName); + } + return bytes; + } + private static int GetRequiredLength(UIntPtr length) { var value = length.ToUInt64(); diff --git a/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 b/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 index b13f8e3..3ac9bd4 100644 --- a/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 +++ b/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 @@ -71,50 +71,9 @@ begin {} process { - $privateKeyByteArray = $null - $decryptedBytes = $null - try { - $ciphertext = [System.Convert]::FromBase64String($SealedBox) - if ($ciphertext.Length -lt $script:SodiumSealBytes) { - throw "Invalid sealed box. Expected at least $script:SodiumSealBytes bytes but got $($ciphertext.Length)." - } - - $privateKeyByteArray = [System.Convert]::FromBase64String($PrivateKey) - if ($privateKeyByteArray.Length -ne $script:SodiumPrivateKeyBytes) { - throw "Invalid private key. Expected $script:SodiumPrivateKeyBytes bytes but got $($privateKeyByteArray.Length)." - } - - if (-not $PublicKey) { - $publicKeyByteArray = [byte[]]::new($script:SodiumPublicKeyBytes) - $deriveResult = [PSModule.Sodium]::crypto_scalarmult_base($publicKeyByteArray, $privateKeyByteArray) - if ($deriveResult -ne 0) { - throw 'Unable to derive public key from private key.' - } - } else { - $publicKeyByteArray = [System.Convert]::FromBase64String($PublicKey) - if ($publicKeyByteArray.Length -ne $script:SodiumPublicKeyBytes) { - throw "Invalid public key. Expected $script:SodiumPublicKeyBytes bytes but got $($publicKeyByteArray.Length)." - } - } - - $decryptedBytes = [byte[]]::new($ciphertext.Length - $script:SodiumSealBytes) - - $result = [PSModule.Sodium]::crypto_box_seal_open( - $decryptedBytes, $ciphertext, [UInt64]$ciphertext.Length, $publicKeyByteArray, $privateKeyByteArray - ) - - if ($result -ne 0) { - throw 'Decryption failed.' - } - - return [System.Text.Encoding]::UTF8.GetString($decryptedBytes) - } finally { - if ($null -ne $privateKeyByteArray -and $privateKeyByteArray.Length -gt 0) { - [array]::Clear($privateKeyByteArray, 0, $privateKeyByteArray.Length) - } - if ($null -ne $decryptedBytes -and $decryptedBytes.Length -gt 0) { - [array]::Clear($decryptedBytes, 0, $decryptedBytes.Length) - } + if ($PublicKey) { + return [PSModule.Sodium]::OpenSealBase64($SealedBox, $PrivateKey, $PublicKey) } + return [PSModule.Sodium]::OpenSealBase64($SealedBox, $PrivateKey) } } diff --git a/src/functions/public/ConvertTo-SodiumSealedBox.ps1 b/src/functions/public/ConvertTo-SodiumSealedBox.ps1 index 6b8f235..1ea35a1 100644 --- a/src/functions/public/ConvertTo-SodiumSealedBox.ps1 +++ b/src/functions/public/ConvertTo-SodiumSealedBox.ps1 @@ -58,28 +58,6 @@ begin {} process { - $messageBytes = $null - try { - $publicKeyByteArray = [Convert]::FromBase64String($PublicKey) - if ($publicKeyByteArray.Length -ne $script:SodiumPublicKeyBytes) { - throw "Invalid public key. Expected $script:SodiumPublicKeyBytes bytes but got $($publicKeyByteArray.Length)." - } - - $messageBytes = [System.Text.Encoding]::UTF8.GetBytes($Message) - $cipherLength = $messageBytes.Length + $script:SodiumSealBytes - $ciphertext = [byte[]]::new($cipherLength) - - $result = [PSModule.Sodium]::crypto_box_seal($ciphertext, $messageBytes, [uint64]$messageBytes.Length, $publicKeyByteArray) - - if ($result -ne 0) { - throw 'Encryption failed.' - } - - return [Convert]::ToBase64String($ciphertext) - } finally { - if ($null -ne $messageBytes -and $messageBytes.Length -gt 0) { - [array]::Clear($messageBytes, 0, $messageBytes.Length) - } - } + return [PSModule.Sodium]::SealBase64($Message, $PublicKey) } } diff --git a/src/functions/public/Get-SodiumPublicKey.ps1 b/src/functions/public/Get-SodiumPublicKey.ps1 index 46f2096..5512fc6 100644 --- a/src/functions/public/Get-SodiumPublicKey.ps1 +++ b/src/functions/public/Get-SodiumPublicKey.ps1 @@ -84,27 +84,24 @@ begin {} process { - $privateKeyByteArray = $null - try { - $publicKeyByteArray = [byte[]]::new($script:SodiumPublicKeyBytes) - $privateKeyByteArray = [System.Convert]::FromBase64String($PrivateKey) - if ($privateKeyByteArray.Length -ne $script:SodiumPrivateKeyBytes) { - throw "Invalid private key. Expected $script:SodiumPrivateKeyBytes bytes but got $($privateKeyByteArray.Length)." - } - - $deriveResult = [PSModule.Sodium]::crypto_scalarmult_base($publicKeyByteArray, $privateKeyByteArray) - if ($deriveResult -ne 0) { throw 'Unable to derive public key from private key.' } - - if ($AsByteArray) { - return $publicKeyByteArray - } else { - return [System.Convert]::ToBase64String($publicKeyByteArray) - } - } finally { - if ($null -ne $privateKeyByteArray -and $privateKeyByteArray.Length -gt 0) { - [array]::Clear($privateKeyByteArray, 0, $privateKeyByteArray.Length) + if ($AsByteArray) { + $privateKeyByteArray = $null + try { + $publicKeyByteArray = [byte[]]::new($script:SodiumPublicKeyBytes) + $privateKeyByteArray = [System.Convert]::FromBase64String($PrivateKey) + if ($privateKeyByteArray.Length -ne $script:SodiumPrivateKeyBytes) { + throw "Invalid private key. Expected $script:SodiumPrivateKeyBytes bytes but got $($privateKeyByteArray.Length)." + } + $deriveResult = [PSModule.Sodium]::crypto_scalarmult_base($publicKeyByteArray, $privateKeyByteArray) + if ($deriveResult -ne 0) { throw 'Unable to derive public key from private key.' } + return , $publicKeyByteArray + } finally { + if ($null -ne $privateKeyByteArray -and $privateKeyByteArray.Length -gt 0) { + [array]::Clear($privateKeyByteArray, 0, $privateKeyByteArray.Length) + } } } + return [PSModule.Sodium]::DerivePublicKeyBase64($PrivateKey) } end {} diff --git a/src/functions/public/New-SodiumKeyPair.ps1 b/src/functions/public/New-SodiumKeyPair.ps1 index 44fb29f..9e94a31 100644 --- a/src/functions/public/New-SodiumKeyPair.ps1 +++ b/src/functions/public/New-SodiumKeyPair.ps1 @@ -87,41 +87,14 @@ begin {} process { - $publicKey = [byte[]]::new($script:SodiumPublicKeyBytes) - $privateKey = [byte[]]::new($script:SodiumPrivateKeyBytes) - $seedBytes = $null - $derivedSeed = $null - try { - switch ($PSCmdlet.ParameterSetName) { - 'SeededKeyPair' { - $seedBytes = [System.Text.Encoding]::UTF8.GetBytes($Seed) - $derivedSeed = [System.Security.Cryptography.SHA256]::HashData($seedBytes) - $result = [PSModule.Sodium]::crypto_box_seed_keypair($publicKey, $privateKey, $derivedSeed) - break - } - default { - $result = [PSModule.Sodium]::crypto_box_keypair($publicKey, $privateKey) - } - } - - if ($result -ne 0) { - throw 'Key pair generation failed.' - } - - return [pscustomobject]@{ - PublicKey = [Convert]::ToBase64String($publicKey) - PrivateKey = [Convert]::ToBase64String($privateKey) - } - } finally { - if ($null -ne $privateKey -and $privateKey.Length -gt 0) { - [array]::Clear($privateKey, 0, $privateKey.Length) - } - if ($null -ne $seedBytes -and $seedBytes.Length -gt 0) { - [array]::Clear($seedBytes, 0, $seedBytes.Length) - } - if ($null -ne $derivedSeed -and $derivedSeed.Length -gt 0) { - [array]::Clear($derivedSeed, 0, $derivedSeed.Length) - } + if ($PSCmdlet.ParameterSetName -eq 'SeededKeyPair') { + $kp = [PSModule.Sodium]::GenerateKeyPairBase64($Seed) + } else { + $kp = [PSModule.Sodium]::GenerateKeyPairBase64() + } + return [pscustomobject]@{ + PublicKey = $kp.PublicKey + PrivateKey = $kp.PrivateKey } } } From d9fe73bd15cd8d5bc17c730eeecadf1d1472950f Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 13:47:58 +0200 Subject: [PATCH 13/22] fix: revert comma operator on Get-SodiumPublicKey byte[] return PSScriptAnalyzer's PSUseOutputTypeCorrectly rule sees ',\' as System.Object[]. Match the original pattern. --- src/functions/public/Get-SodiumPublicKey.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functions/public/Get-SodiumPublicKey.ps1 b/src/functions/public/Get-SodiumPublicKey.ps1 index 5512fc6..6aee7fe 100644 --- a/src/functions/public/Get-SodiumPublicKey.ps1 +++ b/src/functions/public/Get-SodiumPublicKey.ps1 @@ -94,7 +94,7 @@ } $deriveResult = [PSModule.Sodium]::crypto_scalarmult_base($publicKeyByteArray, $privateKeyByteArray) if ($deriveResult -ne 0) { throw 'Unable to derive public key from private key.' } - return , $publicKeyByteArray + return $publicKeyByteArray } finally { if ($null -ne $privateKeyByteArray -and $privateKeyByteArray.Length -gt 0) { [array]::Clear($privateKeyByteArray, 0, $privateKeyByteArray.Length) From 31dd6e61ee62fdce3cfbc09be08315c18cde0ec5 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 13:54:00 +0200 Subject: [PATCH 14/22] fix: preserve original cmdlet error messages and unwrap MethodInvocationException The base64-centric C# methods produced exceptions whose messages PowerShell wrapped as 'Exception calling X with Y argument(s): ...', breaking tests that assert against the original message. Restore InvalidOperationException with the original wording and rethrow the InnerException from each cmdlet. --- PSModule/Sodium/Sodium.cs | 12 ++++++------ src/functions/public/ConvertFrom-SodiumSealedBox.ps1 | 10 +++++++--- src/functions/public/ConvertTo-SodiumSealedBox.ps1 | 6 +++++- src/functions/public/Get-SodiumPublicKey.ps1 | 6 +++++- src/functions/public/New-SodiumKeyPair.ps1 | 12 ++++++++---- 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/PSModule/Sodium/Sodium.cs b/PSModule/Sodium/Sodium.cs index 218e890..617cb26 100644 --- a/PSModule/Sodium/Sodium.cs +++ b/PSModule/Sodium/Sodium.cs @@ -225,7 +225,7 @@ public static string SealBase64(string plaintext, string publicKeyBase64) { ArgumentNullException.ThrowIfNull(plaintext); ArgumentNullException.ThrowIfNull(publicKeyBase64); - var publicKey = DecodeBase64Exact(publicKeyBase64, PublicKeyBytes, nameof(publicKeyBase64)); + var publicKey = DecodeBase64Exact(publicKeyBase64, PublicKeyBytes, "public key"); var message = Encoding.UTF8.GetBytes(plaintext); var ciphertext = new byte[message.Length + SealBytes]; if (Native.crypto_box_seal(ciphertext, message, (ulong)message.LongLength, publicKey) != 0) @@ -253,9 +253,9 @@ private static string OpenSealBase64Core(string ciphertextBase64, string private var ciphertext = Convert.FromBase64String(ciphertextBase64); if (ciphertext.Length < SealBytes) { - throw new ArgumentException($"Invalid sealed box. Expected at least {SealBytes} bytes but got {ciphertext.Length}.", nameof(ciphertextBase64)); + throw new InvalidOperationException($"Invalid sealed box. Expected at least {SealBytes} bytes but got {ciphertext.Length}."); } - var privateKey = DecodeBase64Exact(privateKeyBase64, SecretKeyBytes, nameof(privateKeyBase64)); + var privateKey = DecodeBase64Exact(privateKeyBase64, SecretKeyBytes, "private key"); var publicKey = new byte[PublicKeyBytes]; var decrypted = new byte[ciphertext.Length - SealBytes]; try @@ -269,7 +269,7 @@ private static string OpenSealBase64Core(string ciphertextBase64, string private } else { - var providedPk = DecodeBase64Exact(publicKeyBase64, PublicKeyBytes, nameof(publicKeyBase64)); + var providedPk = DecodeBase64Exact(publicKeyBase64, PublicKeyBytes, "public key"); Buffer.BlockCopy(providedPk, 0, publicKey, 0, PublicKeyBytes); } @@ -286,12 +286,12 @@ private static string OpenSealBase64Core(string ciphertextBase64, string private } } - private static byte[] DecodeBase64Exact(string value, int expectedLength, string parameterName) + private static byte[] DecodeBase64Exact(string value, int expectedLength, string label) { var bytes = Convert.FromBase64String(value); if (bytes.Length != expectedLength) { - throw new ArgumentException($"Invalid base64 value. Expected {expectedLength} bytes but got {bytes.Length}.", parameterName); + throw new InvalidOperationException($"Invalid {label}. Expected {expectedLength} bytes but got {bytes.Length}."); } return bytes; } diff --git a/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 b/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 index 3ac9bd4..3eb4ced 100644 --- a/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 +++ b/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 @@ -71,9 +71,13 @@ begin {} process { - if ($PublicKey) { - return [PSModule.Sodium]::OpenSealBase64($SealedBox, $PrivateKey, $PublicKey) + try { + if ($PublicKey) { + return [PSModule.Sodium]::OpenSealBase64($SealedBox, $PrivateKey, $PublicKey) + } + return [PSModule.Sodium]::OpenSealBase64($SealedBox, $PrivateKey) + } catch [System.Management.Automation.MethodInvocationException] { + throw $_.Exception.InnerException } - return [PSModule.Sodium]::OpenSealBase64($SealedBox, $PrivateKey) } } diff --git a/src/functions/public/ConvertTo-SodiumSealedBox.ps1 b/src/functions/public/ConvertTo-SodiumSealedBox.ps1 index 1ea35a1..3f52d83 100644 --- a/src/functions/public/ConvertTo-SodiumSealedBox.ps1 +++ b/src/functions/public/ConvertTo-SodiumSealedBox.ps1 @@ -58,6 +58,10 @@ begin {} process { - return [PSModule.Sodium]::SealBase64($Message, $PublicKey) + try { + return [PSModule.Sodium]::SealBase64($Message, $PublicKey) + } catch [System.Management.Automation.MethodInvocationException] { + throw $_.Exception.InnerException + } } } diff --git a/src/functions/public/Get-SodiumPublicKey.ps1 b/src/functions/public/Get-SodiumPublicKey.ps1 index 6aee7fe..ce47a1f 100644 --- a/src/functions/public/Get-SodiumPublicKey.ps1 +++ b/src/functions/public/Get-SodiumPublicKey.ps1 @@ -101,7 +101,11 @@ } } } - return [PSModule.Sodium]::DerivePublicKeyBase64($PrivateKey) + try { + return [PSModule.Sodium]::DerivePublicKeyBase64($PrivateKey) + } catch [System.Management.Automation.MethodInvocationException] { + throw $_.Exception.InnerException + } } end {} diff --git a/src/functions/public/New-SodiumKeyPair.ps1 b/src/functions/public/New-SodiumKeyPair.ps1 index 9e94a31..b4e46db 100644 --- a/src/functions/public/New-SodiumKeyPair.ps1 +++ b/src/functions/public/New-SodiumKeyPair.ps1 @@ -87,10 +87,14 @@ begin {} process { - if ($PSCmdlet.ParameterSetName -eq 'SeededKeyPair') { - $kp = [PSModule.Sodium]::GenerateKeyPairBase64($Seed) - } else { - $kp = [PSModule.Sodium]::GenerateKeyPairBase64() + try { + if ($PSCmdlet.ParameterSetName -eq 'SeededKeyPair') { + $kp = [PSModule.Sodium]::GenerateKeyPairBase64($Seed) + } else { + $kp = [PSModule.Sodium]::GenerateKeyPairBase64() + } + } catch [System.Management.Automation.MethodInvocationException] { + throw $_.Exception.InnerException } return [pscustomobject]@{ PublicKey = $kp.PublicKey From 12923e6dde15346049c1802e027da8a2ed72eb5b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 13:58:42 +0200 Subject: [PATCH 15/22] fix: use 'private key' label in DerivePublicKeyBase64 error --- PSModule/Sodium/Sodium.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PSModule/Sodium/Sodium.cs b/PSModule/Sodium/Sodium.cs index 617cb26..192b28a 100644 --- a/PSModule/Sodium/Sodium.cs +++ b/PSModule/Sodium/Sodium.cs @@ -205,7 +205,7 @@ public static KeyPairBase64 GenerateKeyPairBase64(string seedText) public static string DerivePublicKeyBase64(string privateKeyBase64) { ArgumentNullException.ThrowIfNull(privateKeyBase64); - var privateKey = DecodeBase64Exact(privateKeyBase64, SecretKeyBytes, nameof(privateKeyBase64)); + var privateKey = DecodeBase64Exact(privateKeyBase64, SecretKeyBytes, "private key"); var publicKey = new byte[PublicKeyBytes]; try { From 1df65b569f7ec45e3400350c201cd5967a8ee37a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 14:05:00 +0200 Subject: [PATCH 16/22] docs: add PERFORMANCE.md with per-issue benchmark breakdown for #48-#54 --- PERFORMANCE.md | 163 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 PERFORMANCE.md diff --git a/PERFORMANCE.md b/PERFORMANCE.md new file mode 100644 index 0000000..a11d00a --- /dev/null +++ b/PERFORMANCE.md @@ -0,0 +1,163 @@ +# Sodium PowerShell Module — Performance Improvements + +This document captures the cumulative performance work landed in the +[`fix/44-harden-sodium-interop`](https://github.com/PSModule/Sodium/tree/fix/44-harden-sodium-interop) +branch (PR #45) that hardens the Sodium interop layer. Each issue listed below +was implemented and benchmarked independently against the previous prerelease, +so the incremental contribution of every change can be attributed precisely. + +## Methodology + +- Each improvement was committed to the same branch, published to the PowerShell + Gallery as an incrementing prerelease of `2.2.3-fix44hardensodiuminterop00X`, + and benchmarked from a clean PowerShell session on Windows 11 (x64). +- Each scenario was executed as **5 trials of 1,000 iterations**; the reported + number is the median per-iteration time in microseconds (µs). +- Cold start is measured as 5 isolated runs of "import module + generate one + key pair" in a fresh `pwsh -NoProfile` process; reported number is the + median total time in microseconds. +- Raw measurements live in `tools/perf/results.jsonl` (git-ignored, local-only). +- Benchmark scenarios: + - `New-SodiumKeyPair` — generate a random key pair. + - `New-SodiumKeyPair-Seeded` — generate a deterministic key pair from a UTF-8 seed. + - `Get-SodiumPublicKey` — derive the public key from a base64 private key. + - `ConvertTo-SodiumSealedBox` — seal a short plaintext for a recipient. + - `ConvertFrom-SodiumSealedBox` — open a sealed box (private key only). + - `ColdStart-Import+OneKeyPair` — full module import + one key pair. + +## Cumulative results + +All numbers are median µs per iteration. Δ is relative to the **baseline**. + +| Scenario | Baseline | Final (#52) | Δ | +| ------------------------------ | -------: | ----------: | ----: | +| New-SodiumKeyPair | 73.6 | 49.1 | −33 % | +| New-SodiumKeyPair-Seeded | 94.9 | 48.8 | −49 % | +| Get-SodiumPublicKey | 66.1 | 46.7 | −29 % | +| ConvertTo-SodiumSealedBox | 135.8 | 105.4 | −22 % | +| ConvertFrom-SodiumSealedBox | 196.3 | 109.0 | −44 % | +| ColdStart-Import+OneKeyPair | 287,076 | 279,362 | −3 % | + +The warm-path scenarios all dropped 20–49 %; the dominant share of that came +from moving conversion and validation work into the C# layer (#52) and from +caching libsodium-reported size constants (#48, #51). + +## Per-issue contribution (median µs) + +Each row shows the median for the prerelease that *introduced* the change and +its incremental delta versus the previous prerelease. + +### [#50 — Use `SHA256.HashData` for seed derivation](https://github.com/PSModule/Sodium/issues/50) + +Replaces the allocating `SHA256.Create().ComputeHash(...)` pattern with the +static `SHA256.HashData` API. + +| Scenario | Prev | This | Δ | +| ------------------------------ | ----: | ----: | ----: | +| New-SodiumKeyPair-Seeded | 94.9 | 87.6 | −8 % | +| Others | – | – | ~noise | + +### [#54 — Defer Visual C++ Redistributable probe to first failure](https://github.com/PSModule/Sodium/issues/54) + +Moves the (expensive) registry walk that detects `vcruntime140.dll` out of the +module import path; the probe only runs if `sodium_init` actually fails. No +warm-path win on its own (within noise) but reduces import-time risk on +machines without the runtime installed. + +| Scenario | Prev | This | Δ | +| ------------------------------ | ----: | ----: | ----: | +| ColdStart-Import+OneKeyPair | 284.8 ms | 300.9 ms | within noise | + +### [#49 — Inline `crypto_scalarmult_base` in sealed-box open](https://github.com/PSModule/Sodium/issues/49) + +Derives the recipient public key in-place inside `ConvertFrom-SodiumSealedBox` +instead of calling back into `Get-SodiumPublicKey`, eliminating one cmdlet +dispatch + one base64 round-trip per open. + +| Scenario | Prev | This | Δ | +| ------------------------------ | ----: | ----: | ----: | +| ConvertFrom-SodiumSealedBox | 219.4 | 167.2 | **−24 %** | + +### [#53 — Initialize libsodium at module import](https://github.com/PSModule/Sodium/issues/53) + +Calls `sodium_init` once during module load instead of lazily on first cmdlet +invocation. Removes a per-call init check from every code path. + +| Scenario | Prev | This | Δ | +| ------------------------------ | ----: | ----: | ----: | +| New-SodiumKeyPair | 75.7 | 66.7 | −12 % | +| New-SodiumKeyPair-Seeded | 92.0 | 82.5 | −10 % | +| Get-SodiumPublicKey | 70.2 | 60.3 | −14 % | +| ConvertTo-SodiumSealedBox | 141.1 | 133.0 | −6 % | +| ConvertFrom-SodiumSealedBox | 167.2 | 161.8 | −3 % | +| ColdStart-Import+OneKeyPair | 292.9 ms | 280.7 ms | −4 % | + +### [#51 — Cache libsodium size constants in C#](https://github.com/PSModule/Sodium/issues/51) + +Reads `crypto_box_publickeybytes`, `secretkeybytes`, `sealbytes` and +`seedbytes` once into `static readonly` ints; subsequent allocations avoid the +P/Invoke per call. Also fixes a long-standing bug where `New-SodiumKeyPair +-Seed` validated against `SecretKeyBytes` (32) instead of `SeedBytes` (32 too, +but coincidentally — the validation was conceptually wrong). + +| Scenario | Prev | This | Δ | +| ------------------------------ | ----: | ----: | ----: | +| New-SodiumKeyPair | 66.7 | 65.7 | −1 % | +| New-SodiumKeyPair-Seeded | 82.5 | 80.0 | −3 % | +| Get-SodiumPublicKey | 60.3 | 57.7 | −4 % | +| ConvertTo-SodiumSealedBox | 133.0 | 126.7 | −5 % | +| ConvertFrom-SodiumSealedBox | 161.8 | 155.4 | −4 % | + +### [#48 — Migrate `DllImport` to `LibraryImport` source generator](https://github.com/PSModule/Sodium/issues/48) + +Switches every libsodium binding to `[LibraryImport]` partial methods, lets +the source generator emit the marshalling stubs at compile time, and makes +the assembly AOT-ready. Warm-path change is within measurement noise — the +value is in eliminating runtime IL stub generation and unblocking future AOT +publishing. + +| Scenario | Prev | This | Δ | +| ------------------------------ | ----: | ----: | ----: | +| New-SodiumKeyPair | 65.7 | 66.5 | +1 % | +| Others | ~noise | | | + +### [#52 — Move base64 conversion into C#](https://github.com/PSModule/Sodium/issues/52) + +The PowerShell cmdlets previously called byte-array C# APIs and did all +`Convert.FromBase64String` / `Convert.ToBase64String` work in PS, paying the +PowerShell engine cost for one extra reflective .NET call per cmdlet and +allocating intermediate arrays in two languages. This change adds +`SealBase64`, `OpenSealBase64`, `DerivePublicKeyBase64`, and +`GenerateKeyPairBase64` overloads that take and return base64 strings +directly, so each cmdlet now performs a single C# call. + +This is the largest single contribution to the warm path. + +| Scenario | Prev | This | Δ | +| ------------------------------ | ----: | ----: | ----: | +| New-SodiumKeyPair | 66.5 | 49.1 | **−26 %** | +| New-SodiumKeyPair-Seeded | 83.7 | 48.8 | **−42 %** | +| Get-SodiumPublicKey | 59.1 | 46.7 | **−21 %** | +| ConvertTo-SodiumSealedBox | 127.6 | 105.4 | **−17 %** | +| ConvertFrom-SodiumSealedBox | 158.0 | 109.0 | **−31 %** | + +## Biggest wins + +1. **#52 (base64 in C#)** — single largest contributor, 17–42 % on every warm + scenario. +2. **#53 (init at import)** — 6–14 % across the board for the first round of + warm-path savings. +3. **#49 (inline scalarmult)** — a focused 24 % win on the sealed-box open path. + +## Verifying locally + +The benchmark harness lives in `tools/perf/` (git-ignored). To reproduce: + +```powershell +pwsh -NoProfile -File tools/perf/Invoke-Benchmark.ps1 ` + -Version '2.2.3-fix44hardensodiuminterop009' ` + -Label '#52 base64 C# APIs' ` + -Iterations 1000 -Trials 5 -ColdStartIterations 5 +``` + +Results append to `tools/perf/results.jsonl` for later analysis. From e6359c6cc4c5ed921af481aa99d48fbef328f8d3 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 14:38:54 +0200 Subject: [PATCH 17/22] Remove PERFORMANCE.md --- PERFORMANCE.md | 163 ------------------------------------------------- 1 file changed, 163 deletions(-) delete mode 100644 PERFORMANCE.md diff --git a/PERFORMANCE.md b/PERFORMANCE.md deleted file mode 100644 index a11d00a..0000000 --- a/PERFORMANCE.md +++ /dev/null @@ -1,163 +0,0 @@ -# Sodium PowerShell Module — Performance Improvements - -This document captures the cumulative performance work landed in the -[`fix/44-harden-sodium-interop`](https://github.com/PSModule/Sodium/tree/fix/44-harden-sodium-interop) -branch (PR #45) that hardens the Sodium interop layer. Each issue listed below -was implemented and benchmarked independently against the previous prerelease, -so the incremental contribution of every change can be attributed precisely. - -## Methodology - -- Each improvement was committed to the same branch, published to the PowerShell - Gallery as an incrementing prerelease of `2.2.3-fix44hardensodiuminterop00X`, - and benchmarked from a clean PowerShell session on Windows 11 (x64). -- Each scenario was executed as **5 trials of 1,000 iterations**; the reported - number is the median per-iteration time in microseconds (µs). -- Cold start is measured as 5 isolated runs of "import module + generate one - key pair" in a fresh `pwsh -NoProfile` process; reported number is the - median total time in microseconds. -- Raw measurements live in `tools/perf/results.jsonl` (git-ignored, local-only). -- Benchmark scenarios: - - `New-SodiumKeyPair` — generate a random key pair. - - `New-SodiumKeyPair-Seeded` — generate a deterministic key pair from a UTF-8 seed. - - `Get-SodiumPublicKey` — derive the public key from a base64 private key. - - `ConvertTo-SodiumSealedBox` — seal a short plaintext for a recipient. - - `ConvertFrom-SodiumSealedBox` — open a sealed box (private key only). - - `ColdStart-Import+OneKeyPair` — full module import + one key pair. - -## Cumulative results - -All numbers are median µs per iteration. Δ is relative to the **baseline**. - -| Scenario | Baseline | Final (#52) | Δ | -| ------------------------------ | -------: | ----------: | ----: | -| New-SodiumKeyPair | 73.6 | 49.1 | −33 % | -| New-SodiumKeyPair-Seeded | 94.9 | 48.8 | −49 % | -| Get-SodiumPublicKey | 66.1 | 46.7 | −29 % | -| ConvertTo-SodiumSealedBox | 135.8 | 105.4 | −22 % | -| ConvertFrom-SodiumSealedBox | 196.3 | 109.0 | −44 % | -| ColdStart-Import+OneKeyPair | 287,076 | 279,362 | −3 % | - -The warm-path scenarios all dropped 20–49 %; the dominant share of that came -from moving conversion and validation work into the C# layer (#52) and from -caching libsodium-reported size constants (#48, #51). - -## Per-issue contribution (median µs) - -Each row shows the median for the prerelease that *introduced* the change and -its incremental delta versus the previous prerelease. - -### [#50 — Use `SHA256.HashData` for seed derivation](https://github.com/PSModule/Sodium/issues/50) - -Replaces the allocating `SHA256.Create().ComputeHash(...)` pattern with the -static `SHA256.HashData` API. - -| Scenario | Prev | This | Δ | -| ------------------------------ | ----: | ----: | ----: | -| New-SodiumKeyPair-Seeded | 94.9 | 87.6 | −8 % | -| Others | – | – | ~noise | - -### [#54 — Defer Visual C++ Redistributable probe to first failure](https://github.com/PSModule/Sodium/issues/54) - -Moves the (expensive) registry walk that detects `vcruntime140.dll` out of the -module import path; the probe only runs if `sodium_init` actually fails. No -warm-path win on its own (within noise) but reduces import-time risk on -machines without the runtime installed. - -| Scenario | Prev | This | Δ | -| ------------------------------ | ----: | ----: | ----: | -| ColdStart-Import+OneKeyPair | 284.8 ms | 300.9 ms | within noise | - -### [#49 — Inline `crypto_scalarmult_base` in sealed-box open](https://github.com/PSModule/Sodium/issues/49) - -Derives the recipient public key in-place inside `ConvertFrom-SodiumSealedBox` -instead of calling back into `Get-SodiumPublicKey`, eliminating one cmdlet -dispatch + one base64 round-trip per open. - -| Scenario | Prev | This | Δ | -| ------------------------------ | ----: | ----: | ----: | -| ConvertFrom-SodiumSealedBox | 219.4 | 167.2 | **−24 %** | - -### [#53 — Initialize libsodium at module import](https://github.com/PSModule/Sodium/issues/53) - -Calls `sodium_init` once during module load instead of lazily on first cmdlet -invocation. Removes a per-call init check from every code path. - -| Scenario | Prev | This | Δ | -| ------------------------------ | ----: | ----: | ----: | -| New-SodiumKeyPair | 75.7 | 66.7 | −12 % | -| New-SodiumKeyPair-Seeded | 92.0 | 82.5 | −10 % | -| Get-SodiumPublicKey | 70.2 | 60.3 | −14 % | -| ConvertTo-SodiumSealedBox | 141.1 | 133.0 | −6 % | -| ConvertFrom-SodiumSealedBox | 167.2 | 161.8 | −3 % | -| ColdStart-Import+OneKeyPair | 292.9 ms | 280.7 ms | −4 % | - -### [#51 — Cache libsodium size constants in C#](https://github.com/PSModule/Sodium/issues/51) - -Reads `crypto_box_publickeybytes`, `secretkeybytes`, `sealbytes` and -`seedbytes` once into `static readonly` ints; subsequent allocations avoid the -P/Invoke per call. Also fixes a long-standing bug where `New-SodiumKeyPair --Seed` validated against `SecretKeyBytes` (32) instead of `SeedBytes` (32 too, -but coincidentally — the validation was conceptually wrong). - -| Scenario | Prev | This | Δ | -| ------------------------------ | ----: | ----: | ----: | -| New-SodiumKeyPair | 66.7 | 65.7 | −1 % | -| New-SodiumKeyPair-Seeded | 82.5 | 80.0 | −3 % | -| Get-SodiumPublicKey | 60.3 | 57.7 | −4 % | -| ConvertTo-SodiumSealedBox | 133.0 | 126.7 | −5 % | -| ConvertFrom-SodiumSealedBox | 161.8 | 155.4 | −4 % | - -### [#48 — Migrate `DllImport` to `LibraryImport` source generator](https://github.com/PSModule/Sodium/issues/48) - -Switches every libsodium binding to `[LibraryImport]` partial methods, lets -the source generator emit the marshalling stubs at compile time, and makes -the assembly AOT-ready. Warm-path change is within measurement noise — the -value is in eliminating runtime IL stub generation and unblocking future AOT -publishing. - -| Scenario | Prev | This | Δ | -| ------------------------------ | ----: | ----: | ----: | -| New-SodiumKeyPair | 65.7 | 66.5 | +1 % | -| Others | ~noise | | | - -### [#52 — Move base64 conversion into C#](https://github.com/PSModule/Sodium/issues/52) - -The PowerShell cmdlets previously called byte-array C# APIs and did all -`Convert.FromBase64String` / `Convert.ToBase64String` work in PS, paying the -PowerShell engine cost for one extra reflective .NET call per cmdlet and -allocating intermediate arrays in two languages. This change adds -`SealBase64`, `OpenSealBase64`, `DerivePublicKeyBase64`, and -`GenerateKeyPairBase64` overloads that take and return base64 strings -directly, so each cmdlet now performs a single C# call. - -This is the largest single contribution to the warm path. - -| Scenario | Prev | This | Δ | -| ------------------------------ | ----: | ----: | ----: | -| New-SodiumKeyPair | 66.5 | 49.1 | **−26 %** | -| New-SodiumKeyPair-Seeded | 83.7 | 48.8 | **−42 %** | -| Get-SodiumPublicKey | 59.1 | 46.7 | **−21 %** | -| ConvertTo-SodiumSealedBox | 127.6 | 105.4 | **−17 %** | -| ConvertFrom-SodiumSealedBox | 158.0 | 109.0 | **−31 %** | - -## Biggest wins - -1. **#52 (base64 in C#)** — single largest contributor, 17–42 % on every warm - scenario. -2. **#53 (init at import)** — 6–14 % across the board for the first round of - warm-path savings. -3. **#49 (inline scalarmult)** — a focused 24 % win on the sealed-box open path. - -## Verifying locally - -The benchmark harness lives in `tools/perf/` (git-ignored). To reproduce: - -```powershell -pwsh -NoProfile -File tools/perf/Invoke-Benchmark.ps1 ` - -Version '2.2.3-fix44hardensodiuminterop009' ` - -Label '#52 base64 C# APIs' ` - -Iterations 1000 -Trials 5 -ColdStartIterations 5 -``` - -Results append to `tools/perf/results.jsonl` for later analysis. From 9326f7c290e686e3363d383204c315695b374574 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 14:43:22 +0200 Subject: [PATCH 18/22] Update to .NET 10, libsodium 1.0.22, System.Management.Automation 7.6.1 --- PSModule/Sodium.csproj | 6 +++--- PSModule/build.ps1 | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/PSModule/Sodium.csproj b/PSModule/Sodium.csproj index 2a952dd..714668c 100644 --- a/PSModule/Sodium.csproj +++ b/PSModule/Sodium.csproj @@ -1,14 +1,14 @@ - net8.0 + net10.0 PSModule.Sodium true - - + + diff --git a/PSModule/build.ps1 b/PSModule/build.ps1 index 738f89a..f3681fe 100644 --- a/PSModule/build.ps1 +++ b/PSModule/build.ps1 @@ -19,7 +19,7 @@ try { throw "dotnet publish failed for runtime '$_'." } - $source = "$PSScriptRoot/bin/Release/net8.0/$_/publish" + $source = "$PSScriptRoot/bin/Release/net10.0/$_/publish" $destination = "$PSScriptRoot/../src/libs/$_" Copy-Item -Path $source -Destination $destination -Recurse -Force } From b457c6d07bddc2d695ea61584e126106c26d7a41 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 14:56:50 +0200 Subject: [PATCH 19/22] fix: remove local performance harness from .gitignore --- .gitignore | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.gitignore b/.gitignore index c40f7d7..456ca0f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,3 @@ outputs/* bin/ obj/ libs/ - -# Local performance harness (not part of module) -tools/perf/ -tools/Measure-Speed.ps1 From 5e142d1ada47056e747f260de62f3ec7a368ec02 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 15:32:11 +0200 Subject: [PATCH 20/22] Fix PR #45 review findings: init guards, validation consistency, and net8-compatible interop build --- PSModule/Sodium.csproj | 4 ++-- PSModule/Sodium/Sodium.cs | 17 +++++++++++----- PSModule/build.ps1 | 2 +- src/functions/private/Initialize-Sodium.ps1 | 8 +++++++- .../public/ConvertFrom-SodiumSealedBox.ps1 | 6 ++++-- .../public/ConvertTo-SodiumSealedBox.ps1 | 4 +++- src/functions/public/Get-SodiumPublicKey.ps1 | 20 ++++++------------- src/functions/public/New-SodiumKeyPair.ps1 | 6 ++++-- src/main.ps1 | 2 +- src/variables/private/Initialized.ps1 | 1 + tests/Sodium.Tests.ps1 | 12 +++++++++-- 11 files changed, 51 insertions(+), 31 deletions(-) diff --git a/PSModule/Sodium.csproj b/PSModule/Sodium.csproj index 714668c..b121fdd 100644 --- a/PSModule/Sodium.csproj +++ b/PSModule/Sodium.csproj @@ -1,13 +1,13 @@ - net10.0 + net8.0 PSModule.Sodium true - + diff --git a/PSModule/Sodium/Sodium.cs b/PSModule/Sodium/Sodium.cs index 192b28a..08ec69e 100644 --- a/PSModule/Sodium/Sodium.cs +++ b/PSModule/Sodium/Sodium.cs @@ -228,11 +228,18 @@ public static string SealBase64(string plaintext, string publicKeyBase64) var publicKey = DecodeBase64Exact(publicKeyBase64, PublicKeyBytes, "public key"); var message = Encoding.UTF8.GetBytes(plaintext); var ciphertext = new byte[message.Length + SealBytes]; - if (Native.crypto_box_seal(ciphertext, message, (ulong)message.LongLength, publicKey) != 0) + try + { + if (Native.crypto_box_seal(ciphertext, message, (ulong)message.LongLength, publicKey) != 0) + { + throw new InvalidOperationException("Encryption failed."); + } + return Convert.ToBase64String(ciphertext); + } + finally { - throw new InvalidOperationException("Encryption failed."); + CryptographicOperations.ZeroMemory(message); } - return Convert.ToBase64String(ciphertext); } public static string OpenSealBase64(string ciphertextBase64, string privateKeyBase64) @@ -253,7 +260,7 @@ private static string OpenSealBase64Core(string ciphertextBase64, string private var ciphertext = Convert.FromBase64String(ciphertextBase64); if (ciphertext.Length < SealBytes) { - throw new InvalidOperationException($"Invalid sealed box. Expected at least {SealBytes} bytes but got {ciphertext.Length}."); + throw new ArgumentException($"Invalid sealed box. Expected at least {SealBytes} bytes but got {ciphertext.Length}."); } var privateKey = DecodeBase64Exact(privateKeyBase64, SecretKeyBytes, "private key"); var publicKey = new byte[PublicKeyBytes]; @@ -291,7 +298,7 @@ private static byte[] DecodeBase64Exact(string value, int expectedLength, string var bytes = Convert.FromBase64String(value); if (bytes.Length != expectedLength) { - throw new InvalidOperationException($"Invalid {label}. Expected {expectedLength} bytes but got {bytes.Length}."); + throw new ArgumentException($"Invalid {label}. Expected {expectedLength} bytes but got {bytes.Length}."); } return bytes; } diff --git a/PSModule/build.ps1 b/PSModule/build.ps1 index f3681fe..738f89a 100644 --- a/PSModule/build.ps1 +++ b/PSModule/build.ps1 @@ -19,7 +19,7 @@ try { throw "dotnet publish failed for runtime '$_'." } - $source = "$PSScriptRoot/bin/Release/net10.0/$_/publish" + $source = "$PSScriptRoot/bin/Release/net8.0/$_/publish" $destination = "$PSScriptRoot/../src/libs/$_" Copy-Item -Path $source -Destination $destination -Recurse -Force } diff --git a/src/functions/private/Initialize-Sodium.ps1 b/src/functions/private/Initialize-Sodium.ps1 index 85502e0..837a6a1 100644 --- a/src/functions/private/Initialize-Sodium.ps1 +++ b/src/functions/private/Initialize-Sodium.ps1 @@ -22,8 +22,14 @@ try { $initializationResult = [PSModule.Sodium]::sodium_init() } catch { + $script:Supported = $false if ($IsWindows) { - $script:Supported = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture $script:ProcessArchitecture + if ($script:ProcessArchitecture -in @('X64', 'X86')) { + $hasRuntime = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture $script:ProcessArchitecture + if (-not $hasRuntime) { + throw "Sodium native initialization failed; the Visual C++ Redistributable for $($script:ProcessArchitecture) appears to be missing or below the required version." + } + } } throw } diff --git a/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 b/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 index 3eb4ced..5b72e4a 100644 --- a/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 +++ b/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 @@ -68,11 +68,13 @@ [string] $PrivateKey ) - begin {} + begin { + Initialize-Sodium + } process { try { - if ($PublicKey) { + if (-not [string]::IsNullOrWhiteSpace($PublicKey)) { return [PSModule.Sodium]::OpenSealBase64($SealedBox, $PrivateKey, $PublicKey) } return [PSModule.Sodium]::OpenSealBase64($SealedBox, $PrivateKey) diff --git a/src/functions/public/ConvertTo-SodiumSealedBox.ps1 b/src/functions/public/ConvertTo-SodiumSealedBox.ps1 index 3f52d83..ff87d42 100644 --- a/src/functions/public/ConvertTo-SodiumSealedBox.ps1 +++ b/src/functions/public/ConvertTo-SodiumSealedBox.ps1 @@ -55,7 +55,9 @@ [ValidateNotNullOrEmpty()] [string] $PublicKey ) - begin {} + begin { + Initialize-Sodium + } process { try { diff --git a/src/functions/public/Get-SodiumPublicKey.ps1 b/src/functions/public/Get-SodiumPublicKey.ps1 index ce47a1f..8a7ab08 100644 --- a/src/functions/public/Get-SodiumPublicKey.ps1 +++ b/src/functions/public/Get-SodiumPublicKey.ps1 @@ -81,24 +81,16 @@ [switch] $AsByteArray ) - begin {} + begin { + Initialize-Sodium + } process { if ($AsByteArray) { - $privateKeyByteArray = $null try { - $publicKeyByteArray = [byte[]]::new($script:SodiumPublicKeyBytes) - $privateKeyByteArray = [System.Convert]::FromBase64String($PrivateKey) - if ($privateKeyByteArray.Length -ne $script:SodiumPrivateKeyBytes) { - throw "Invalid private key. Expected $script:SodiumPrivateKeyBytes bytes but got $($privateKeyByteArray.Length)." - } - $deriveResult = [PSModule.Sodium]::crypto_scalarmult_base($publicKeyByteArray, $privateKeyByteArray) - if ($deriveResult -ne 0) { throw 'Unable to derive public key from private key.' } - return $publicKeyByteArray - } finally { - if ($null -ne $privateKeyByteArray -and $privateKeyByteArray.Length -gt 0) { - [array]::Clear($privateKeyByteArray, 0, $privateKeyByteArray.Length) - } + return [System.Convert]::FromBase64String([PSModule.Sodium]::DerivePublicKeyBase64($PrivateKey)) + } catch [System.Management.Automation.MethodInvocationException] { + throw $_.Exception.InnerException } } try { diff --git a/src/functions/public/New-SodiumKeyPair.ps1 b/src/functions/public/New-SodiumKeyPair.ps1 index b4e46db..a8bebe5 100644 --- a/src/functions/public/New-SodiumKeyPair.ps1 +++ b/src/functions/public/New-SodiumKeyPair.ps1 @@ -80,11 +80,13 @@ ParameterSetName = 'SeededKeyPair', ValueFromPipeline )] - [ValidateNotNullOrEmpty()] + [AllowEmptyString()] [string] $Seed ) - begin {} + begin { + Initialize-Sodium + } process { try { diff --git a/src/main.ps1 b/src/main.ps1 index 71987f6..0d1d6f2 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -36,7 +36,7 @@ switch ($true) { $assemblyPath = Join-Path -Path $PSScriptRoot -ChildPath "libs/$runtimeIdentifier/PSModule.Sodium.dll" Import-Module $assemblyPath -ErrorAction Stop -# Optimistically mark supported; Initialize-Sodium will run the Windows VC++ runtime check lazily only if native init fails. +# Optimistically mark supported; Initialize-Sodium runs during module import and checks Windows VC++ runtime only if native init fails. $script:Supported = $true $script:ProcessArchitecture = $processArchitecture.ToString() diff --git a/src/variables/private/Initialized.ps1 b/src/variables/private/Initialized.ps1 index 9025085..868f814 100644 --- a/src/variables/private/Initialized.ps1 +++ b/src/variables/private/Initialized.ps1 @@ -2,3 +2,4 @@ $script:SodiumPublicKeyBytes = $null $script:SodiumPrivateKeyBytes = $null $script:SodiumSealBytes = $null +$script:SodiumSeedBytes = $null diff --git a/tests/Sodium.Tests.ps1 b/tests/Sodium.Tests.ps1 index e8a00bc..84f8fb1 100644 --- a/tests/Sodium.Tests.ps1 +++ b/tests/Sodium.Tests.ps1 @@ -140,6 +140,14 @@ $keyPair1.PublicKey | Should -Not -Be $keyPair2.PublicKey $keyPair1.PrivateKey | Should -Not -Be $keyPair2.PrivateKey } + + It 'Allows an empty seed and remains deterministic for compatibility' { + $keyPair1 = New-SodiumKeyPair -Seed '' + $keyPair2 = New-SodiumKeyPair -Seed '' + + $keyPair1.PublicKey | Should -Be $keyPair2.PublicKey + $keyPair1.PrivateKey | Should -Be $keyPair2.PrivateKey + } } Context 'Public Key Derivation' { @@ -168,11 +176,11 @@ } Context 'Runtime diagnostics' { - It 'Assert-VisualCRedistributableInstalled returns a boolean for the current architecture' { + It 'Assert-VisualCRedistributableInstalled validates the current Windows architecture runtime' -Skip:(-not $IsWindows) { InModuleScope Sodium { $arch = if ([System.Environment]::Is64BitProcess) { 'X64' } else { 'X86' } $result = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture $arch 3>$null - $result | Should -BeOfType [bool] + $result | Should -BeTrue } } } From 8599c018b76af990094ab5e39e86b0e30380a700 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 15:36:55 +0200 Subject: [PATCH 21/22] Fix lint: wrap Initialize-Sodium VC++ diagnostic message --- src/functions/private/Initialize-Sodium.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/functions/private/Initialize-Sodium.ps1 b/src/functions/private/Initialize-Sodium.ps1 index 837a6a1..ed07949 100644 --- a/src/functions/private/Initialize-Sodium.ps1 +++ b/src/functions/private/Initialize-Sodium.ps1 @@ -27,7 +27,9 @@ if ($script:ProcessArchitecture -in @('X64', 'X86')) { $hasRuntime = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture $script:ProcessArchitecture if (-not $hasRuntime) { - throw "Sodium native initialization failed; the Visual C++ Redistributable for $($script:ProcessArchitecture) appears to be missing or below the required version." + $message = "Sodium native initialization failed; the Visual C++ Redistributable for " + + "$($script:ProcessArchitecture) appears to be missing or below the required version." + throw $message } } } From c35e5e5caa84e5e135b7cbc99ab48779b21282d3 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 15:55:24 +0200 Subject: [PATCH 22/22] Fix PSUseConsistentIndentation lint error on line 31 --- src/functions/private/Initialize-Sodium.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functions/private/Initialize-Sodium.ps1 b/src/functions/private/Initialize-Sodium.ps1 index ed07949..2ad47bb 100644 --- a/src/functions/private/Initialize-Sodium.ps1 +++ b/src/functions/private/Initialize-Sodium.ps1 @@ -28,7 +28,7 @@ $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." + "$($script:ProcessArchitecture) appears to be missing or below the required version." throw $message } }