Skip to content

Commit 44fed7f

Browse files
🩹 [Patch]: Secret crypto operations now fail fast on invalid input with lower per-call overhead (#45)
Secret encryption and decryption operations in Sodium now validate malformed inputs earlier, return clearer errors for invalid key and payload shapes, and run substantially faster on repeated invocations thanks to a series of targeted performance improvements across the interop layer. - Fixes #44 - Fixes #48 - Fixes #49 - Fixes #50 - Fixes #51 - Fixes #52 - Fixes #53 - Fixes #54 ## Fixed: Invalid key and sealed payload shapes now fail fast with clear errors \Get-SodiumPublicKey\ now rejects wrong-length private keys before key derivation, and \ConvertFrom-SodiumSealedBox\ now rejects sealed payloads shorter than the required Sodium overhead. This prevents low-level failures and returns actionable validation messages earlier in the command flow. Example: \Get-SodiumPublicKey\ rejects a 16-byte private key with 'Invalid private key. Expected 32 bytes but got 16.' Similarly, \ConvertFrom-SodiumSealedBox\ rejects sealed boxes shorter than 48 bytes (the crypto_box_seal overhead). ## Changed: Repeated crypto calls now incur significantly less overhead Seven performance issues were addressed incrementally. Each was benchmarked in isolation against the previous prerelease. All measurements in median µs per 1,000-iteration trial on Windows x64. | Scenario | Baseline | Final | Δ | |---|---:|---:|---:| | New-SodiumKeyPair | 73.6 µs | 49.1 µs | ↓33% | | New-SodiumKeyPair -Seed | 94.9 µs | 48.8 µs | ↓49% | | Get-SodiumPublicKey | 66.1 µs | 46.7 µs | ↓29% | | ConvertTo-SodiumSealedBox | 135.8 µs | 105.4 µs | ↓22% | | ConvertFrom-SodiumSealedBox | 196.3 µs | 109.0 µs | ↓44% | | Cold start (import + one key pair) | 287 ms | 279 ms | ↓3% | Largest contributors: Base64 conversions moved to C# (↓17 to ↓42%), inlining crypto_scalarmult_base in sealed-box open (↓24%), and initializing libsodium at module import (↓6 to ↓14%). ## Changed: Runtime loading behavior is now more deterministic across platforms Runtime selection resolves from process architecture across Windows, Linux, and macOS; Windows support validation aligns with the active process architecture for more predictable module startup on mixed-architecture systems. ## Implementation - Added managed validation wrappers in PSModule/Sodium/Sodium.cs with buffer size checks before unmanaged execution. - Migrated all DllImport bindings to LibraryImport source generators for compile-time marshalling. - Added base64-centric C# helpers (SealBase64, OpenSealBase64, DerivePublicKeyBase64, GenerateKeyPairBase64). - Cached crypto_box size constants as static readonly fields; fixed seed validation to use SeedBytes instead of SecretKeyBytes. - Added Initialize-Sodium at module load to call sodium_init once. - Unwrap MethodInvocationException to surface raw error messages to callers. - Updated Assert-VisualCRedistributableInstalled to validate architecture-specific runtime. - Updated PSModule/build.ps1 to fail fast on per-runtime publish failures. - Added regression coverage for short sealed-box payloads and wrong-length private keys. - Built and tested against .NET 8.0 LTS with System.Management.Automation 7.4.7 for broad compatibility.
1 parent a054711 commit 44fed7f

12 files changed

Lines changed: 519 additions & 139 deletions

‎PSModule/Sodium.csproj‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
<PropertyGroup>
44
<TargetFramework>net8.0</TargetFramework>
55
<AssemblyName>PSModule.Sodium</AssemblyName>
6+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
67
</PropertyGroup>
78

89
<ItemGroup>
9-
<PackageReference Include="System.Management.Automation" Version="7.4.0" PrivateAssets="All" />
10-
<PackageReference Include="libsodium" Version="1.0.20.1" />
10+
<PackageReference Include="System.Management.Automation" Version="7.4.7" PrivateAssets="All" />
11+
<PackageReference Include="libsodium" Version="1.0.22" />
1112
</ItemGroup>
1213

1314
</Project>

‎PSModule/Sodium/Sodium.cs‎

Lines changed: 325 additions & 19 deletions
Large diffs are not rendered by default.

‎PSModule/build.ps1‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
$ErrorActionPreference = 'Stop'
2+
13
Remove-Item -Path "$PSScriptRoot/../src/libs" -Recurse -Force -ErrorAction SilentlyContinue
24

35
$targetRuntimes = @(
@@ -10,13 +12,20 @@ $targetRuntimes = @(
1012
)
1113

1214
Push-Location $PSScriptRoot
13-
$targetRuntimes | ForEach-Object {
14-
dotnet publish -r $_
15-
$source = "$PSScriptRoot/bin/Release/net8.0/$_/publish"
16-
$destination = "$PSScriptRoot/../src/libs/$_"
17-
Copy-Item -Path $source -Destination $destination -Recurse -Force
15+
try {
16+
$targetRuntimes | ForEach-Object {
17+
dotnet publish -c Release -r $_
18+
if ($LASTEXITCODE -ne 0) {
19+
throw "dotnet publish failed for runtime '$_'."
20+
}
21+
22+
$source = "$PSScriptRoot/bin/Release/net8.0/$_/publish"
23+
$destination = "$PSScriptRoot/../src/libs/$_"
24+
Copy-Item -Path $source -Destination $destination -Recurse -Force
25+
}
26+
} finally {
27+
Pop-Location
1828
}
19-
Pop-Location
2029

2130
Get-ChildItem -Path $PSScriptRoot -Directory -Recurse | Where-Object { $_.Name -in 'bin', 'obj' } | ForEach-Object {
2231
Write-Warning "Deleting $($_.FullName)"

‎src/functions/private/Assert-VisualCRedistributableInstalled.ps1‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,37 @@ function Assert-VisualCRedistributableInstalled {
2222
#>
2323
[CmdletBinding()]
2424
[OutputType([bool])]
25-
param (
25+
param(
2626
# The minimum required version of the Visual C++ Redistributable.
2727
[Parameter(Mandatory)]
28-
[Version] $Version
28+
[Version] $Version,
29+
30+
# The process architecture that determines which Redistributable runtime is required.
31+
[Parameter()]
32+
[ValidateSet('X64', 'X86')]
33+
[string] $Architecture = $(if ([System.Environment]::Is64BitProcess) { 'X64' } else { 'X86' })
2934
)
3035

31-
process {
36+
begin {
3237
$result = $false
38+
}
39+
40+
process {
3341
if ($IsWindows) {
34-
$key = 'HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\X64'
35-
if (Test-Path -Path $key) {
36-
$installedVersion = (Get-ItemProperty -Path $key).Version
37-
$result = [Version]($installedVersion.SubString(1, $installedVersion.Length - 1)) -ge $Version
42+
$key = "HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\$Architecture"
43+
$runtimeInfo = Get-ItemProperty -Path $key -ErrorAction SilentlyContinue
44+
if ($runtimeInfo -and $runtimeInfo.Version -and ($runtimeInfo.Installed -ne 0)) {
45+
$installedVersion = [Version]$runtimeInfo.Version.TrimStart('v', 'V')
46+
$result = $installedVersion -ge $Version
3847
}
3948
}
4049
if (-not $result) {
41-
Write-Warning 'The Visual C++ Redistributable for Visual Studio 2015 or later is required.'
50+
Write-Warning "The Visual C++ Redistributable for Visual Studio 2015 or later ($Architecture) is required."
4251
Write-Warning 'Download and install the appropriate version from:'
4352
Write-Warning ' - https://support.microsoft.com/en-us/help/2977003/the-latest-supported-visual-c-downloads'
4453
}
4554
$result
4655
}
56+
57+
end {}
4758
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
function Initialize-Sodium {
2+
<#
3+
.SYNOPSIS
4+
Initializes Sodium for cryptographic operations.
5+
6+
.DESCRIPTION
7+
Initializes the native Sodium library once per module session and caches fixed buffer sizes used by the public commands.
8+
9+
.NOTES
10+
Requires the platform-specific PSModule.Sodium assembly and native libsodium runtime to be loaded.
11+
#>
12+
[OutputType([void])]
13+
[CmdletBinding()]
14+
param()
15+
16+
begin {}
17+
18+
process {
19+
if (-not $script:Supported) { throw 'Sodium is not supported on this platform.' }
20+
if ($script:SodiumInitialized) { return }
21+
22+
try {
23+
$initializationResult = [PSModule.Sodium]::sodium_init()
24+
} catch {
25+
$script:Supported = $false
26+
if ($IsWindows) {
27+
if ($script:ProcessArchitecture -in @('X64', 'X86')) {
28+
$hasRuntime = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture $script:ProcessArchitecture
29+
if (-not $hasRuntime) {
30+
$message = "Sodium native initialization failed; the Visual C++ Redistributable for " +
31+
"$($script:ProcessArchitecture) appears to be missing or below the required version."
32+
throw $message
33+
}
34+
}
35+
}
36+
throw
37+
}
38+
if ($initializationResult -lt 0) {
39+
throw 'Sodium initialization failed.'
40+
}
41+
42+
$script:SodiumPublicKeyBytes = [PSModule.Sodium]::crypto_box_publickeybytes().ToUInt32()
43+
$script:SodiumPrivateKeyBytes = [PSModule.Sodium]::crypto_box_secretkeybytes().ToUInt32()
44+
$script:SodiumSealBytes = [PSModule.Sodium]::crypto_box_sealbytes().ToUInt32()
45+
$script:SodiumSeedBytes = [PSModule.Sodium]::crypto_box_seedbytes().ToUInt32()
46+
$script:SodiumInitialized = $true
47+
}
48+
49+
end {}
50+
}

‎src/functions/public/ConvertFrom-SodiumSealedBox.ps1‎

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
ValueFromPipelineByPropertyName
5656
)]
5757
[Alias('CipherText')]
58+
[ValidateNotNullOrEmpty()]
5859
[string] $SealedBox,
5960

6061
# The base64-encoded public key used for decryption.
@@ -63,38 +64,22 @@
6364

6465
# The base64-encoded private key used for decryption.
6566
[Parameter(Mandatory)]
67+
[ValidateNotNullOrEmpty()]
6668
[string] $PrivateKey
6769
)
6870

6971
begin {
70-
if (-not $script:Supported) { throw 'Sodium is not supported on this platform.' }
71-
$null = [PSModule.Sodium]::sodium_init()
72+
Initialize-Sodium
7273
}
7374

7475
process {
75-
$ciphertext = [System.Convert]::FromBase64String($SealedBox)
76-
77-
$privateKeyByteArray = [System.Convert]::FromBase64String($PrivateKey)
78-
if ($privateKeyByteArray.Length -ne 32) { throw 'Invalid private key.' }
79-
80-
if ([string]::IsNullOrWhiteSpace($PublicKey)) {
81-
$publicKeyByteArray = Get-SodiumPublicKey -PrivateKey $PrivateKey -AsByteArray
82-
} else {
83-
$publicKeyByteArray = [System.Convert]::FromBase64String($PublicKey)
84-
if ($publicKeyByteArray.Length -ne 32) { throw 'Invalid public key.' }
76+
try {
77+
if (-not [string]::IsNullOrWhiteSpace($PublicKey)) {
78+
return [PSModule.Sodium]::OpenSealBase64($SealedBox, $PrivateKey, $PublicKey)
79+
}
80+
return [PSModule.Sodium]::OpenSealBase64($SealedBox, $PrivateKey)
81+
} catch [System.Management.Automation.MethodInvocationException] {
82+
throw $_.Exception.InnerException
8583
}
86-
87-
$overhead = [PSModule.Sodium]::crypto_box_sealbytes().ToUInt32()
88-
$decryptedBytes = New-Object byte[] ($ciphertext.Length - $overhead)
89-
90-
$result = [PSModule.Sodium]::crypto_box_seal_open(
91-
$decryptedBytes, $ciphertext, [UInt64]$ciphertext.Length, $publicKeyByteArray, $privateKeyByteArray
92-
)
93-
94-
if ($result -ne 0) {
95-
throw 'Decryption failed.'
96-
}
97-
98-
return [System.Text.Encoding]::UTF8.GetString($decryptedBytes)
9984
}
10085
}

‎src/functions/public/ConvertTo-SodiumSealedBox.ps1‎

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -52,36 +52,18 @@
5252

5353
# The base64-encoded public key used for encryption.
5454
[Parameter(Mandatory)]
55+
[ValidateNotNullOrEmpty()]
5556
[string] $PublicKey
5657
)
5758
begin {
58-
if (-not $script:Supported) { throw 'Sodium is not supported on this platform.' }
59-
$null = [PSModule.Sodium]::sodium_init()
59+
Initialize-Sodium
6060
}
6161

6262
process {
63-
# Convert public key from Base64 or space-separated string
6463
try {
65-
$publicKeyByteArray = [Convert]::FromBase64String($PublicKey)
66-
} catch {
67-
$PSCmdlet.ThrowTerminatingError($_)
64+
return [PSModule.Sodium]::SealBase64($Message, $PublicKey)
65+
} catch [System.Management.Automation.MethodInvocationException] {
66+
throw $_.Exception.InnerException
6867
}
69-
if ($publicKeyByteArray.Length -ne 32) {
70-
throw "Invalid public key. Expected 32 bytes but got $($publicKeyByteArray.Length)."
71-
}
72-
73-
$messageBytes = [System.Text.Encoding]::UTF8.GetBytes($Message)
74-
$overhead = [PSModule.Sodium]::crypto_box_sealbytes().ToUInt32()
75-
$cipherLength = $messageBytes.Length + $overhead
76-
$ciphertext = New-Object byte[] $cipherLength
77-
78-
# Encrypt message
79-
$result = [PSModule.Sodium]::crypto_box_seal($ciphertext, $messageBytes, [uint64]$messageBytes.Length, $publicKeyByteArray)
80-
81-
if ($result -ne 0) {
82-
throw 'Encryption failed.'
83-
}
84-
85-
return [Convert]::ToBase64String($ciphertext)
8668
}
8769
}

‎src/functions/public/Get-SodiumPublicKey.ps1‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,10 @@
7070
[OutputType([string], ParameterSetName = 'Base64')]
7171
[OutputType([byte[]], ParameterSetName = 'AsByteArray')]
7272
[CmdletBinding(DefaultParameterSetName = 'Base64')]
73-
[CmdletBinding()]
7473
param(
7574
# The private key to derive the public key from.
7675
[Parameter(Mandatory)]
76+
[ValidateNotNullOrEmpty()]
7777
[string] $PrivateKey,
7878

7979
# Returns the byte array
@@ -82,22 +82,23 @@
8282
)
8383

8484
begin {
85-
if (-not $script:Supported) { throw 'Sodium is not supported on this platform.' }
86-
$null = [PSModule.Sodium]::sodium_init()
85+
Initialize-Sodium
8786
}
8887

8988
process {
90-
$publicKeyByteArray = New-Object byte[] 32
91-
$privateKeyByteArray = [System.Convert]::FromBase64String($PrivateKey)
92-
$rc = [PSModule.Sodium]::crypto_scalarmult_base($publicKeyByteArray, $privateKeyByteArray)
93-
if ($rc -ne 0) { throw 'Unable to derive public key from private key.' }
94-
}
95-
96-
end {
9789
if ($AsByteArray) {
98-
return $publicKeyByteArray
99-
} else {
100-
return [System.Convert]::ToBase64String($publicKeyByteArray)
90+
try {
91+
return [System.Convert]::FromBase64String([PSModule.Sodium]::DerivePublicKeyBase64($PrivateKey))
92+
} catch [System.Management.Automation.MethodInvocationException] {
93+
throw $_.Exception.InnerException
94+
}
95+
}
96+
try {
97+
return [PSModule.Sodium]::DerivePublicKeyBase64($PrivateKey)
98+
} catch [System.Management.Automation.MethodInvocationException] {
99+
throw $_.Exception.InnerException
101100
}
102101
}
102+
103+
end {}
103104
}

‎src/functions/public/New-SodiumKeyPair.ps1‎

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -80,40 +80,27 @@
8080
ParameterSetName = 'SeededKeyPair',
8181
ValueFromPipeline
8282
)]
83+
[AllowEmptyString()]
8384
[string] $Seed
8485
)
8586

8687
begin {
87-
if (-not $script:Supported) { throw 'Sodium is not supported on this platform.' }
88-
$null = [PSModule.Sodium]::sodium_init()
88+
Initialize-Sodium
8989
}
9090

9191
process {
92-
$pkSize = [PSModule.Sodium]::crypto_box_publickeybytes().ToUInt32()
93-
$skSize = [PSModule.Sodium]::crypto_box_secretkeybytes().ToUInt32()
94-
$publicKey = New-Object byte[] $pkSize
95-
$privateKey = New-Object byte[] $skSize
96-
97-
switch ($PSCmdlet.ParameterSetName) {
98-
'SeededKeyPair' {
99-
# Derive a 32-byte seed from the provided string seed (using SHA-256)
100-
$seedBytes = [System.Text.Encoding]::UTF8.GetBytes($Seed)
101-
$derivedSeed = [System.Security.Cryptography.SHA256]::Create().ComputeHash($seedBytes)
102-
$result = [PSModule.Sodium]::crypto_box_seed_keypair($publicKey, $privateKey, $derivedSeed)
103-
break
92+
try {
93+
if ($PSCmdlet.ParameterSetName -eq 'SeededKeyPair') {
94+
$kp = [PSModule.Sodium]::GenerateKeyPairBase64($Seed)
95+
} else {
96+
$kp = [PSModule.Sodium]::GenerateKeyPairBase64()
10497
}
105-
default {
106-
$result = [PSModule.Sodium]::crypto_box_keypair($publicKey, $privateKey)
107-
}
108-
}
109-
110-
if ($result -ne 0) {
111-
throw 'Key pair generation failed.'
98+
} catch [System.Management.Automation.MethodInvocationException] {
99+
throw $_.Exception.InnerException
112100
}
113-
114101
return [pscustomobject]@{
115-
PublicKey = [Convert]::ToBase64String($publicKey)
116-
PrivateKey = [Convert]::ToBase64String($privateKey)
102+
PublicKey = $kp.PublicKey
103+
PrivateKey = $kp.PrivateKey
117104
}
118105
}
119106
}

0 commit comments

Comments
 (0)