Private/KeypairGen.psm1

#!/usr/bin/env pwsh
using namespace System.Security
using namespace System.Security.Cryptography

using module ./Exceptions.psm1

#region KeypairGen_Dataclasses

class Keypair {
  [AsymmetricAlgorithm] $Algorithm
  [byte[]] $PublicKey
  [byte[]] $PrivateKey
  [int] $KeySize
  [datetime] $CreatedAt
  [string] $CurveName
  [hashtable] $Parameters

  Keypair() {
    $this.CreatedAt = [datetime]::UtcNow
    $this.Parameters = @{}
  }

  Keypair([AsymmetricAlgorithm]$algorithm) {
    $this.Algorithm = $algorithm
    $this.CreatedAt = [datetime]::UtcNow
    $this.Parameters = @{}
  }

  [string] ToBase64Public() {
    if ($null -eq $this.PublicKey) { return $null }
    return [Convert]::ToBase64String($this.PublicKey)
  }

  [string] ToBase64Private() {
    if ($null -eq $this.PrivateKey) { return $null }
    return [Convert]::ToBase64String($this.PrivateKey)
  }

  [string] ToHexPublic() {
    if ($null -eq $this.PublicKey) { return $null }
    return [KeypairHelper]::BytesToHex($this.PublicKey)
  }

  [string] ToHexPrivate() {
    if ($null -eq $this.PrivateKey) { return $null }
    return [KeypairHelper]::BytesToHex($this.PrivateKey)
  }

  [System.Security.SecureString] ToSecureStringPrivate() {
    if ($null -eq $this.PrivateKey) { return $null }
    return [KeypairHelper]::ToSecureString($this.PrivateKey)
  }

  [int] GetKeySizeInBits() {
    if ($this.KeySize -gt 0) { return $this.KeySize }
    if ($null -ne $this.PublicKey -and $this.PublicKey.Length -gt 0) { return $this.PublicKey.Length * 8 }
    if ($null -ne $this.PrivateKey -and $this.PrivateKey.Length -gt 0) { return $this.PrivateKey.Length * 8 }
    return 0
  }

  [bool] HasPrivateKey() {
    return $null -ne $this.PrivateKey -and $this.PrivateKey.Length -gt 0
  }

  [bool] HasPublicKey() {
    return $null -ne $this.PublicKey -and $this.PublicKey.Length -gt 0
  }

  [hashtable] ToHashtable() {
    return @{
      Algorithm        = $this.Algorithm.ToString()
      PublicKeyBase64  = $this.ToBase64Public()
      PrivateKeyBase64 = $this.ToBase64Private()
      KeySize          = $this.KeySize
      CreatedAt        = $this.CreatedAt.ToString('o')
      CurveName        = $this.CurveName
      Parameters       = $this.Parameters
    }
  }

  static [Keypair] FromHashtable([hashtable]$data) {
    if ($null -eq $data) { throw [System.ArgumentNullException]::new('data') }

    $kp = [Keypair]::new()
    if ($data.ContainsKey('Algorithm') -and $null -ne $data.Algorithm) {
      $kp.Algorithm = [AsymmetricAlgorithm]::Parse([AsymmetricAlgorithm], [string]$data.Algorithm)
    }

    if ($data.ContainsKey('KeySize')) { $kp.KeySize = [int]$data.KeySize }
    if ($data.ContainsKey('CreatedAt') -and $data.CreatedAt) { $kp.CreatedAt = [datetime]::Parse([string]$data.CreatedAt) }
    if ($data.ContainsKey('CurveName')) { $kp.CurveName = [string]$data.CurveName }
    if ($data.ContainsKey('Parameters') -and $data.Parameters -is [hashtable]) { $kp.Parameters = $data.Parameters }

    if ($data.ContainsKey('PublicKeyBase64') -and $data.PublicKeyBase64) {
      $kp.PublicKey = [Convert]::FromBase64String([string]$data.PublicKeyBase64)
    }

    if ($data.ContainsKey('PrivateKeyBase64') -and $data.PrivateKeyBase64) {
      $kp.PrivateKey = [Convert]::FromBase64String([string]$data.PrivateKeyBase64)
    }

    return $kp
  }

  [string] ToString() {
    return "Keypair[Algorithm=$($this.Algorithm), KeySize=$($this.KeySize), Curve=$($this.CurveName)]"
  }
}

class NamedKeypair : Keypair {
  [string] $Name
  [string] $Description
  [string[]] $Tags
  [string] $Owner
  [datetime] $ExpiresAt

  NamedKeypair() : base() {
    $this.Tags = @()
  }

  NamedKeypair([AsymmetricAlgorithm]$algorithm, [string]$name) : base($algorithm) {
    $this.Name = $name
    $this.Tags = @()
  }

  [bool] IsExpired() {
    if ($null -eq $this.ExpiresAt) { return $false }
    return [datetime]::UtcNow -gt $this.ExpiresAt
  }

  [void] AddTag([string]$tag) {
    if ([string]::IsNullOrWhiteSpace($tag)) { return }
    if ($null -eq $this.Tags) { $this.Tags = @() }
    if ($this.Tags -notcontains $tag) { $this.Tags += $tag }
  }

  [void] RemoveTag([string]$tag) {
    if ($null -eq $this.Tags) { return }
    $this.Tags = @($this.Tags | Where-Object { $_ -ne $tag })
  }
}

class KeypairGenerationResult {
  [bool] $Success
  [Keypair] $Keypair
  [string] $ErrorMessage
  [string[]] $Warnings
  [TimeSpan] $Duration
  [datetime] $GeneratedAt

  KeypairGenerationResult() {
    $this.Warnings = @()
    $this.GeneratedAt = [datetime]::UtcNow
  }

  static [KeypairGenerationResult] Successful([Keypair]$keypair, [TimeSpan]$duration) {
    $result = [KeypairGenerationResult]::new()
    $result.Success = $true
    $result.Keypair = $keypair
    $result.Duration = $duration
    return $result
  }

  static [KeypairGenerationResult] Failed([string]$errorMessage, [TimeSpan]$duration) {
    $result = [KeypairGenerationResult]::new()
    $result.Success = $false
    $result.ErrorMessage = $errorMessage
    $result.Duration = $duration
    return $result
  }

  [void] AddWarning([string]$warning) {
    if ([string]::IsNullOrWhiteSpace($warning)) { return }
    $this.Warnings += $warning
  }
}

#endregion


#region KeypairGen_UtilityClasses

class KeypairHelper {
  static [byte[]] HexToBytes([string]$hex) {
    if ([string]::IsNullOrWhiteSpace($hex)) { return [byte[]]@() }
    if ($hex.StartsWith('0x')) { $hex = $hex.Substring(2) }
    if (($hex.Length % 2) -ne 0) { throw [System.FormatException]::new('Hex string must contain an even number of characters.') }

    $bytes = [byte[]]::new($hex.Length / 2)
    for ($i = 0; $i -lt $bytes.Length; $i++) {
      $bytes[$i] = [Convert]::ToByte($hex.Substring($i * 2, 2), 16)
    }
    return $bytes
  }

  static [string] BytesToHex([byte[]]$bytes) {
    if ($null -eq $bytes) { return $null }
    return ([BitConverter]::ToString($bytes)).Replace('-', '').ToLowerInvariant()
  }

  static [string] BytesToBase64([byte[]]$bytes) {
    if ($null -eq $bytes) { return $null }
    return [Convert]::ToBase64String($bytes)
  }

  static [byte[]] Base64ToBytes([string]$base64) {
    if ([string]::IsNullOrWhiteSpace($base64)) { return [byte[]]@() }
    return [Convert]::FromBase64String($base64)
  }

  static [System.Security.SecureString] ToSecureString([byte[]]$bytes) {
    $text = [System.Text.Encoding]::UTF8.GetString($bytes)
    return [xconvert]::ToSecurestring($text)
  }

  static [byte[]] FromSecureString([System.Security.SecureString]$secureString) {
    $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureString)
    try {
      $text = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
      return [System.Text.Encoding]::UTF8.GetBytes($text)
    } finally {
      [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
    }
  }

  static [int] GetKeySizeForCurve([string]$curveName) {
    if ([string]::IsNullOrWhiteSpace($curveName)) { return 0 }
    $s = switch ($curveName.ToLowerInvariant()) {
      'secp256k1' { 256; break }
      'secp256r1' { 256; break }
      'secp384r1' { 384; break }
      'secp521r1' { 521; break }
      'ed25519' { 256; break }
      'ed448' { 448; break }
      'x25519' { 256; break }
      'x448' { 448; break }
      default { return 0 }
    }
    return $s
  }

  static [bool] IsSupportedAlgorithm([AsymmetricAlgorithm]$algorithm) {
    $unsupported = @(
      [AsymmetricAlgorithm]::KYBER,
      [AsymmetricAlgorithm]::DILITHIUM,
      [AsymmetricAlgorithm]::SPHINCS,
      [AsymmetricAlgorithm]::ELGAMAL,
      [AsymmetricAlgorithm]::X25519,
      [AsymmetricAlgorithm]::X448,
      [AsymmetricAlgorithm]::CURVE25519
    )

    return $unsupported -notcontains $algorithm
  }
}

#endregion

#region KeypairGen_mainclass
# .SYNOPSIS
# KeypairGenerator class library for PowerShell.
# .EXAMPLE
# # Generate an RSA keypair
# $rsa = [KeypairGen]::Generate([AsymmetricAlgorithm]::RSA, 2048)
# $rsa.ToBase64Public()
# # Generate ECDSA keypair
# $ecdsa = [KeypairGen]::Generate([AsymmetricAlgorithm]::ECDSA, 256, 'secp256r1')
# # Use manager
# $manager = [KeypairManager]::new()
# $manager.Add('signing', $rsa)
# $manager.Get('signing')
# .Notes
# - Post-quantum and some curve algorithms listed in `AsymmetricAlgorithm` are placeholders for future implementation.
# - Current implementation focuses on stable .NET-backed algorithms and class-based API completeness.
class KeypairGen {
  static [bool] $VerboseOutput = $false

  static [Keypair] Generate([AsymmetricAlgorithm]$Algorithm) {
    return [KeypairGen]::Generate($Algorithm, 0, $null)
  }

  static [Keypair] Generate([AsymmetricAlgorithm]$Algorithm, [int]$KeySize) {
    return [KeypairGen]::Generate($Algorithm, $KeySize, $null)
  }

  static [Keypair] Generate([AsymmetricAlgorithm]$Algorithm, [int]$KeySize, [string]$Curve) {
    $stopwatch = [System.Diagnostics.Stopwatch]::StartNew(); $kp = $null
    try {
      $resolvedCurve = $Curve

      $kp = switch ($Algorithm) {
        ([AsymmetricAlgorithm]::RSA) {
          $size = if ($KeySize -gt 0) { $KeySize } else { 2048 }
          [KeypairGen]::GenerateRSA($size)
          break
        }
        ([AsymmetricAlgorithm]::DSA) {
          $size = if ($KeySize -gt 0) { $KeySize } else { 2048 }
          [KeypairGen]::GenerateDSA($size)
          break
        }
        ([AsymmetricAlgorithm]::DIFFIE_HELLMAN) {
          $size = if ($KeySize -gt 0) { $KeySize } else { 2048 }
          [KeypairGen]::GenerateDiffieHellman($size)
          break
        }
        ([AsymmetricAlgorithm]::ECDSA) {
          if ([string]::IsNullOrWhiteSpace($resolvedCurve)) {
            $resolvedCurve = if ($KeySize -ge 384) { 'secp384r1' } else { 'secp256r1' }
          }
          [KeypairGen]::GenerateECDsa($resolvedCurve)
          break
        }
        ([AsymmetricAlgorithm]::ECDH) {
          if ([string]::IsNullOrWhiteSpace($resolvedCurve)) {
            $resolvedCurve = if ($KeySize -ge 384) { 'secp384r1' } else { 'secp256r1' }
          }
          [KeypairGen]::GenerateECDH($resolvedCurve)
          break
        }
        ([AsymmetricAlgorithm]::SECP256K1) { [KeypairGen]::GenerateECDH('secp256k1'); break }
        ([AsymmetricAlgorithm]::SECP256R1) { [KeypairGen]::GenerateECDsa('secp256r1'); break }
        ([AsymmetricAlgorithm]::SECP384R1) { [KeypairGen]::GenerateECDsa('secp384r1'); break }
        ([AsymmetricAlgorithm]::SECP521R1) { [KeypairGen]::GenerateECDsa('secp521r1'); break }
        ([AsymmetricAlgorithm]::ED25519) { [KeypairGen]::GenerateEdCurve('ed25519'); break }
        ([AsymmetricAlgorithm]::ED448) { [KeypairGen]::GenerateEdCurve('ed448'); break }
        default {
          throw [KeyGenerationException]::new("Algorithm '$Algorithm' is not currently implemented in this library-only module.", $Algorithm)
        }
      }
    } catch {
      throw [KeyGenerationException]::new("Failed to generate keypair for $Algorithm : $($_.Exception.Message)", $Algorithm)
    } finally {
      $stopwatch.Stop()
      if ([KeypairGen]::VerboseOutput) {
        Write-Verbose "Generated $Algorithm keypair in $($stopwatch.ElapsedMilliseconds)ms"
      }
    }
    return $kp
  }

  static [KeypairGenerationResult] GenerateWithResult([AsymmetricAlgorithm]$Algorithm, [int]$KeySize, [string]$Curve) {
    $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
    try {
      $kp = [KeypairGen]::Generate($Algorithm, $KeySize, $Curve)
      $stopwatch.Stop()
      return [KeypairGenerationResult]::Successful($kp, $stopwatch.Elapsed)
    } catch {
      $stopwatch.Stop()
      return [KeypairGenerationResult]::Failed($_.Exception.Message, $stopwatch.Elapsed)
    }
  }

  static [KeypairGenerationResult] GenerateWithResult([AsymmetricAlgorithm]$Algorithm) {
    return [KeypairGen]::GenerateWithResult($Algorithm, 0, $null)
  }

  static [Keypair] GenerateRSA([int]$keySize) {
    $rsa = [System.Security.Cryptography.RSA]::Create($keySize)
    try {
      $kp = [Keypair]::new([AsymmetricAlgorithm]::RSA)
      $kp.KeySize = $keySize
      $kp.PublicKey = $rsa.ExportRSAPublicKey()
      $kp.PrivateKey = $rsa.ExportRSAPrivateKey()
      return $kp
    } finally {
      $rsa.Dispose()
    }
  }

  static [Keypair] GenerateDSA([int]$keySize) {
    $dsa = [System.Security.Cryptography.DSA]::Create($keySize)
    try {
      $kp = [Keypair]::new([AsymmetricAlgorithm]::DSA)
      $kp.KeySize = $keySize
      $kp.PublicKey = $dsa.ExportSubjectPublicKeyInfo()
      $kp.PrivateKey = $dsa.ExportPkcs8PrivateKey()
      return $kp
    } finally {
      $dsa.Dispose()
    }
  }

  static [Keypair] GenerateDiffieHellman([int]$keySize) {
    $dh = [System.Security.Cryptography.ECDiffieHellman]::Create()
    try {
      $dh.KeySize = $keySize
      $kp = [Keypair]::new([AsymmetricAlgorithm]::DIFFIE_HELLMAN)
      $kp.KeySize = $keySize
      $kp.PublicKey = $dh.ExportSubjectPublicKeyInfo()
      $kp.PrivateKey = $dh.ExportPkcs8PrivateKey()
      return $kp
    } finally {
      $dh.Dispose()
    }
  }

  static [Keypair] GenerateECDsa([string]$curveName) {
    $ecdsa = [System.Security.Cryptography.ECDsa]::Create([System.Security.Cryptography.ECCurve]::CreateFromFriendlyName($curveName))
    try {
      $kp = [Keypair]::new([AsymmetricAlgorithm]::ECDSA)
      $kp.CurveName = $curveName
      $kp.KeySize = [KeypairHelper]::GetKeySizeForCurve($curveName)
      $kp.PublicKey = $ecdsa.ExportSubjectPublicKeyInfo()
      $kp.PrivateKey = $ecdsa.ExportPkcs8PrivateKey()
      return $kp
    } finally {
      $ecdsa.Dispose()
    }
  }

  static [Keypair] GenerateECDH([string]$curveName) {
    $ecdh = [System.Security.Cryptography.ECDiffieHellman]::Create([System.Security.Cryptography.ECCurve]::CreateFromFriendlyName($curveName))
    try {
      $kp = [Keypair]::new([AsymmetricAlgorithm]::ECDH)
      $kp.CurveName = $curveName
      $kp.KeySize = [KeypairHelper]::GetKeySizeForCurve($curveName)
      $kp.PublicKey = $ecdh.ExportSubjectPublicKeyInfo()
      $kp.PrivateKey = $ecdh.ExportPkcs8PrivateKey()
      return $kp
    } finally {
      $ecdh.Dispose()
    }
  }

  static [Keypair] GenerateEdCurve([string]$curveName) {
    $isEd448   = $curveName -ieq 'ed448'
    $algorithm = if ($isEd448) { [AsymmetricAlgorithm]::ED448 } else { [AsymmetricAlgorithm]::ED25519 }
    $typeName  = if ($isEd448) {
      "System.Security.Cryptography.Ed448, System.Security.Cryptography"
    } else {
      "System.Security.Cryptography.Ed25519, System.Security.Cryptography"
    }

    $edType = [System.type]::GetType($typeName)
    if ($null -ne $edType) {
      $ed = $edType::new()
      try {
        $kp = [Keypair]::new($algorithm)
        $kp.CurveName  = $curveName
        $kp.KeySize    = [KeypairHelper]::GetKeySizeForCurve($curveName)
        $kp.PublicKey  = $ed.ExportSubjectPublicKeyInfo()
        $kp.PrivateKey = $ed.ExportPkcs8PrivateKey()
        return $kp
      } finally {
        $ed.Dispose()
      }
    }

    # For Ed25519 without .NET 8: generate a random seed as PrivateKey.
    # Callers should use [Ed25519]::new().GenerateKeyPair() for full key pair with public key.
    if (-not $isEd448) {
      $seed = [byte[]]::new(32)
      [System.Security.Cryptography.RandomNumberGenerator]::Fill($seed)
      $kp = [Keypair]::new([AsymmetricAlgorithm]::ED25519)
      $kp.CurveName  = 'ed25519'
      $kp.KeySize    = 256
      $kp.PrivateKey = $seed
      # PublicKey will be $null here; use [Ed25519]::GetPublicKey() to derive it
      return $kp
    }

    throw [System.PlatformNotSupportedException]::new("$curveName requires .NET 8+ or external library")
  }

  static [object[]] GetAlgorithmInfo() {
    return @(
      [pscustomobject]@{ Name = 'RSA'; Native = $true; KeySizes = @(1024, 2048, 3072, 4096) },
      [pscustomobject]@{ Name = 'DSA'; Native = $true; KeySizes = @(1024, 2048, 3072) },
      [pscustomobject]@{ Name = 'DIFFIE_HELLMAN'; Native = $true; KeySizes = @(1024, 2048, 3072, 4096) },
      [pscustomobject]@{ Name = 'ECDSA'; Native = $true; Curves = @('secp256r1', 'secp384r1', 'secp521r1') },
      [pscustomobject]@{ Name = 'ECDH'; Native = $true; Curves = @('secp256k1', 'secp256r1', 'secp384r1', 'secp521r1') },
      [pscustomobject]@{ Name = 'ED25519'; Native = $true; Curves = @('ed25519') },
      [pscustomobject]@{ Name = 'ED448'; Native = $true; Curves = @('ed448') },
      [pscustomobject]@{ Name = 'X25519'; Native = $false; Notes = 'Not implemented in this module yet' },
      [pscustomobject]@{ Name = 'X448'; Native = $false; Notes = 'Not implemented in this module yet' },
      [pscustomobject]@{ Name = 'KYBER'; Native = $false; Notes = 'Requires external post-quantum library' }
    )
  }
  static [void] TestKeypairGenerator() {
    # Basic smoke tests for KeypairGenerator class library.
    Write-Host '--- KeypairGenerator Smoke Tests ---'

    $rsa = [KeypairGen]::Generate([AsymmetricAlgorithm]::RSA, 2048)
    Write-Host "RSA: $($rsa.Algorithm), size=$($rsa.KeySize), hasPrivate=$($rsa.HasPrivateKey())"

    $ecdsa = [KeypairGen]::Generate([AsymmetricAlgorithm]::ECDSA, 256, 'secp256r1')
    Write-Host "ECDSA: curve=$($ecdsa.CurveName), pubLen=$($ecdsa.PublicKey.Length)"

    $result = [KeypairGen]::GenerateWithResult([AsymmetricAlgorithm]::DSA)
    Write-Host "GenerateWithResult success=$($result.Success), durationMs=$($result.Duration.TotalMilliseconds)"

    $named = [NamedKeypair]::new([AsymmetricAlgorithm]::RSA, 'demo')
    $named.Description = 'Demo key'
    $named.AddTag('demo')
    $named.PublicKey = $rsa.PublicKey
    $named.PrivateKey = $rsa.PrivateKey
    $named.KeySize = $rsa.KeySize
    Write-Host "Named: name=$($named.Name), tags=$($named.Tags -join ',')"

    $mgr = [KeypairManager]::new()
    $mgr.Add('rsa', $rsa)
    $mgr.Add('ecdsa', $ecdsa)
    Write-Host "Manager count=$($mgr.GetNames().Count), contains-rsa=$($mgr.Contains('rsa'))"
    Write-Host 'All smoke tests completed.'
  }
}

#endregion

#region KeypairGen_Manager

class KeypairManager {
  [hashtable] $Keypairs
  [string] $DefaultPath

  KeypairManager() {
    $this.Keypairs = @{}
  }

  KeypairManager([string]$path) {
    $this.Keypairs = @{}
    $this.DefaultPath = $path
  }

  [void] Add([string]$name, [Keypair]$keypair) {
    if ([string]::IsNullOrWhiteSpace($name)) { throw [System.ArgumentException]::new('Name cannot be empty.', 'name') }
    if ($null -eq $keypair) { throw [System.ArgumentNullException]::new('keypair') }
    $this.Keypairs[$name] = $keypair
  }

  [void] Remove([string]$name) {
    if ($this.Keypairs.ContainsKey($name)) { [void]$this.Keypairs.Remove($name) }
  }

  [Keypair] Get([string]$name) {
    if (-not $this.Keypairs.ContainsKey($name)) { return $null }
    return [Keypair]$this.Keypairs[$name]
  }

  [bool] Contains([string]$name) {
    return $this.Keypairs.ContainsKey($name)
  }

  [string[]] GetNames() {
    return [string[]]$this.Keypairs.Keys
  }

  [Keypair[]] GetAllByAlgorithm([AsymmetricAlgorithm]$algorithm) {
    $result = New-Object System.Collections.Generic.List[Keypair]
    foreach ($kp in $this.Keypairs.Values) {
      if ($kp.Algorithm -eq $algorithm) {
        [void]$result.Add([Keypair]$kp)
      }
    }
    return $result.ToArray()
  }

  [void] Save([string]$path) {
    $target = if ([string]::IsNullOrWhiteSpace($path)) { $this.DefaultPath } else { $path }
    if ([string]::IsNullOrWhiteSpace($target)) { throw [System.ArgumentException]::new('Path is required.', 'path') }

    $data = @{}
    foreach ($entry in $this.Keypairs.GetEnumerator()) {
      $data[$entry.Key] = $entry.Value.ToHashtable()
    }

    $data | ConvertTo-Json -Depth 20 | Set-Content -Path $target -Encoding UTF8
  }

  [void] Load([string]$path) {
    $target = if ([string]::IsNullOrWhiteSpace($path)) { $this.DefaultPath } else { $path }
    if ([string]::IsNullOrWhiteSpace($target)) { throw [System.ArgumentException]::new('Path is required.', 'path') }
    if (-not (Test-Path -Path $target)) { throw [System.IO.FileNotFoundException]::new("File not found: $target") }

    $json = Get-Content -Path $target -Raw
    $content = ConvertFrom-Json -InputObject $json -AsHashtable

    foreach ($name in $content.Keys) {
      $this.Keypairs[$name] = [Keypair]::FromHashtable([hashtable]$content[$name])
    }
  }
}

#endregion