diff --git a/PSModule/Sodium.csproj b/PSModule/Sodium.csproj index 9bfec7c..b121fdd 100644 --- a/PSModule/Sodium.csproj +++ b/PSModule/Sodium.csproj @@ -3,11 +3,12 @@ net8.0 PSModule.Sodium + true - - + + diff --git a/PSModule/Sodium/Sodium.cs b/PSModule/Sodium/Sodium.cs index 44b5f33..08ec69e 100644 --- a/PSModule/Sodium/Sodium.cs +++ b/PSModule/Sodium/Sodium.cs @@ -1,36 +1,342 @@ using System; using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; namespace PSModule { - public static class Sodium + public static partial class Sodium { - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] - public static extern int sodium_init(); + private static partial class Native + { + [LibraryImport("libsodium")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + [UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + public static partial int sodium_init(); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] - public static extern int crypto_box_keypair(byte[] publicKey, byte[] privateKey); + [LibraryImport("libsodium")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + [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)] - public static extern int crypto_box_seed_keypair(byte[] publicKey, byte[] privateKey, byte[] seed); + [LibraryImport("libsodium")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + [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)] - public static extern int crypto_box_seal(byte[] ciphertext, byte[] message, ulong mlen, byte[] publicKey); + [LibraryImport("libsodium")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + [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)] - public static extern int crypto_box_seal_open(byte[] decrypted, byte[] ciphertext, ulong clen, byte[] publicKey, byte[] privateKey); + [LibraryImport("libsodium")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + [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)] - public static extern UIntPtr crypto_box_publickeybytes(); + [LibraryImport("libsodium")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + [UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + public static partial UIntPtr crypto_box_publickeybytes(); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] - public static extern UIntPtr crypto_box_secretkeybytes(); + [LibraryImport("libsodium")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + [UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + public static partial UIntPtr crypto_box_secretkeybytes(); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] - public static extern UIntPtr crypto_box_sealbytes(); + [LibraryImport("libsodium")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + [UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + public static partial UIntPtr crypto_box_sealbytes(); - [DllImport("libsodium", CallingConvention = CallingConvention.Cdecl)] - public static extern int crypto_scalarmult_base(byte[] publicKey, byte[] privateKey); + [LibraryImport("libsodium")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + [UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + public static partial UIntPtr crypto_box_seedbytes(); + [LibraryImport("libsodium")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.SafeDirectories)] + [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(). + 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(); + } + + public static int crypto_box_keypair(byte[] publicKey, byte[] 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, PublicKeyBytes, nameof(publicKey)); + ValidateMinimumBufferLength(privateKey, SecretKeyBytes, nameof(privateKey)); + ValidateExactBufferLength(seed, SeedBytes, 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 + (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 = (ulong)SealBytes; + if (ciphertextLength < sealBytes) + { + throw new ArgumentException($"The ciphertext must be at least {sealBytes} bytes.", nameof(ciphertext)); + } + + ValidateMinimumBufferLength(ciphertext, ciphertextLength, nameof(ciphertext)); + ValidateMinimumBufferLength(decrypted, ciphertextLength - sealBytes, nameof(decrypted)); + 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 (UIntPtr)PublicKeyBytes; + } + + public static UIntPtr crypto_box_secretkeybytes() + { + return (UIntPtr)SecretKeyBytes; + } + + public static UIntPtr 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, PublicKeyBytes, nameof(publicKey)); + ValidateExactBufferLength(privateKey, SecretKeyBytes, nameof(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, "private key"); + 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, "public key"); + var message = Encoding.UTF8.GetBytes(plaintext); + var ciphertext = new byte[message.Length + SealBytes]; + try + { + if (Native.crypto_box_seal(ciphertext, message, (ulong)message.LongLength, publicKey) != 0) + { + throw new InvalidOperationException("Encryption failed."); + } + return Convert.ToBase64String(ciphertext); + } + finally + { + CryptographicOperations.ZeroMemory(message); + } + } + + 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}."); + } + var privateKey = DecodeBase64Exact(privateKeyBase64, SecretKeyBytes, "private key"); + 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, "public key"); + 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 label) + { + var bytes = Convert.FromBase64String(value); + if (bytes.Length != expectedLength) + { + throw new ArgumentException($"Invalid {label}. Expected {expectedLength} bytes but got {bytes.Length}."); + } + return bytes; + } + + 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..2ad47bb --- /dev/null +++ b/src/functions/private/Initialize-Sodium.ps1 @@ -0,0 +1,50 @@ +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 } + + try { + $initializationResult = [PSModule.Sodium]::sodium_init() + } catch { + $script:Supported = $false + if ($IsWindows) { + if ($script:ProcessArchitecture -in @('X64', 'X86')) { + $hasRuntime = Assert-VisualCRedistributableInstalled -Version '14.0' -Architecture $script:ProcessArchitecture + if (-not $hasRuntime) { + $message = "Sodium native initialization failed; the Visual C++ Redistributable for " + + "$($script:ProcessArchitecture) appears to be missing or below the required version." + throw $message + } + } + } + throw + } + if ($initializationResult -lt 0) { + throw 'Sodium initialization failed.' + } + + $script:SodiumPublicKeyBytes = [PSModule.Sodium]::crypto_box_publickeybytes().ToUInt32() + $script:SodiumPrivateKeyBytes = [PSModule.Sodium]::crypto_box_secretkeybytes().ToUInt32() + $script:SodiumSealBytes = [PSModule.Sodium]::crypto_box_sealbytes().ToUInt32() + $script:SodiumSeedBytes = [PSModule.Sodium]::crypto_box_seedbytes().ToUInt32() + $script:SodiumInitialized = $true + } + + end {} +} diff --git a/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 b/src/functions/public/ConvertFrom-SodiumSealedBox.ps1 index 0fcaf62..5b72e4a 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,22 @@ # 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.' } + try { + if (-not [string]::IsNullOrWhiteSpace($PublicKey)) { + return [PSModule.Sodium]::OpenSealBase64($SealedBox, $PrivateKey, $PublicKey) + } + return [PSModule.Sodium]::OpenSealBase64($SealedBox, $PrivateKey) + } catch [System.Management.Automation.MethodInvocationException] { + throw $_.Exception.InnerException } - - $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..ff87d42 100644 --- a/src/functions/public/ConvertTo-SodiumSealedBox.ps1 +++ b/src/functions/public/ConvertTo-SodiumSealedBox.ps1 @@ -52,36 +52,18 @@ # 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 try { - $publicKeyByteArray = [Convert]::FromBase64String($PublicKey) - } catch { - $PSCmdlet.ThrowTerminatingError($_) + return [PSModule.Sodium]::SealBase64($Message, $PublicKey) + } catch [System.Management.Automation.MethodInvocationException] { + throw $_.Exception.InnerException } - if ($publicKeyByteArray.Length -ne 32) { - throw "Invalid public key. Expected 32 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 - - # Encrypt message - $result = [PSModule.Sodium]::crypto_box_seal($ciphertext, $messageBytes, [uint64]$messageBytes.Length, $publicKeyByteArray) - - if ($result -ne 0) { - throw 'Encryption failed.' - } - - return [Convert]::ToBase64String($ciphertext) } } diff --git a/src/functions/public/Get-SodiumPublicKey.ps1 b/src/functions/public/Get-SodiumPublicKey.ps1 index fc75cef..8a7ab08 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,23 @@ ) 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.' } - } - - end { if ($AsByteArray) { - return $publicKeyByteArray - } else { - return [System.Convert]::ToBase64String($publicKeyByteArray) + try { + return [System.Convert]::FromBase64String([PSModule.Sodium]::DerivePublicKeyBase64($PrivateKey)) + } catch [System.Management.Automation.MethodInvocationException] { + throw $_.Exception.InnerException + } + } + 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 c023557..a8bebe5 100644 --- a/src/functions/public/New-SodiumKeyPair.ps1 +++ b/src/functions/public/New-SodiumKeyPair.ps1 @@ -80,40 +80,27 @@ ParameterSetName = 'SeededKeyPair', ValueFromPipeline )] + [AllowEmptyString()] [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 + try { + if ($PSCmdlet.ParameterSetName -eq 'SeededKeyPair') { + $kp = [PSModule.Sodium]::GenerateKeyPairBase64($Seed) + } else { + $kp = [PSModule.Sodium]::GenerateKeyPairBase64() } - default { - $result = [PSModule.Sodium]::crypto_box_keypair($publicKey, $privateKey) - } - } - - if ($result -ne 0) { - throw 'Key pair generation failed.' + } catch [System.Management.Automation.MethodInvocationException] { + throw $_.Exception.InnerException } - return [pscustomobject]@{ - PublicKey = [Convert]::ToBase64String($publicKey) - PrivateKey = [Convert]::ToBase64String($privateKey) + PublicKey = $kp.PublicKey + PrivateKey = $kp.PrivateKey } } } diff --git a/src/main.ps1 b/src/main.ps1 index 69b1199..0d1d6f2 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 + +# 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() + +Initialize-Sodium diff --git a/src/variables/private/Initialized.ps1 b/src/variables/private/Initialized.ps1 new file mode 100644 index 0000000..868f814 --- /dev/null +++ b/src/variables/private/Initialized.ps1 @@ -0,0 +1,5 @@ +$script:SodiumInitialized = $false +$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 bad67a4..84f8fb1 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 @@ -132,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' { @@ -150,5 +166,22 @@ { 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.' + } + } + + Context 'Runtime diagnostics' { + 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 -BeTrue + } + } } }