PSCryptoChat.psm1
|
#Requires -Version 7.0 #Requires -PSEdition Core using namespace System.Security.Cryptography # PSScriptAnalyzer suppressions for intentional security patterns [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Required for SecretManagement vault storage - data is already sensitive and vault provides secure storage')] param() <# .SYNOPSIS PSCryptoChat - Encrypted, decentralized, optionally anonymous messaging .DESCRIPTION A PowerShell module for secure peer-to-peer messaging using: - P-256 ECDH key exchange - AES-GCM authenticated encryption - SecretManagement for identity storage - UDP P2P with STUN hole punching - Optional mDNS LAN discovery .NOTES This is exploratory code - expect breaking changes. #> #region Classes - Must be in .psm1 for type export # ============================================================================== # CryptoProvider - Core cryptographic operations # ============================================================================== class CryptoProvider { # Constants static [int]$KeySize = 256 static [int]$NonceSize = 12 static [int]$TagSize = 16 static [string]$HkdfInfo = "PSCryptoChat-v1" #region Key Generation static [ECDiffieHellman]NewKeyPair() { # Use Windows CNG directly - ECDiffieHellman::Create() returns null on .NET 9/Windows if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { return [ECDiffieHellmanCng]::new(256) } else { # Try generic approach for Linux/macOS $ecdh = [ECDiffieHellman]::Create([ECCurve]::NamedCurves.nistP256) if ($null -eq $ecdh) { throw "Failed to create ECDiffieHellman key pair on this platform" } return $ecdh } } static [string]ExportPublicKey([ECDiffieHellman]$KeyPair) { $bytes = $KeyPair.ExportSubjectPublicKeyInfo() return [Convert]::ToBase64String($bytes) } static [ECDiffieHellman]ImportPublicKey([string]$Base64PublicKey) { $bytes = [Convert]::FromBase64String($Base64PublicKey) # Create new key pair and import the public key if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { $ecdh = [ECDiffieHellmanCng]::new(256) } else { $ecdh = [ECDiffieHellman]::Create([ECCurve]::NamedCurves.nistP256) } $bytesRead = 0 $ecdh.ImportSubjectPublicKeyInfo($bytes, [ref]$bytesRead) return $ecdh } static [string]ExportKeyPair([ECDiffieHellman]$KeyPair) { $params = $KeyPair.ExportParameters($true) $data = @{ Curve = $params.Curve.Oid.FriendlyName X = [Convert]::ToBase64String($params.Q.X) Y = [Convert]::ToBase64String($params.Q.Y) D = [Convert]::ToBase64String($params.D) } return ($data | ConvertTo-Json -Compress) } static [ECDiffieHellman]ImportKeyPair([string]$Json) { $data = $Json | ConvertFrom-Json $params = [ECParameters]::new() $params.Curve = [ECCurve]::NamedCurves.nistP256 $params.Q = [ECPoint]::new() $params.Q.X = [Convert]::FromBase64String($data.X) $params.Q.Y = [Convert]::FromBase64String($data.Y) $params.D = [Convert]::FromBase64String($data.D) if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { $ecdh = [ECDiffieHellmanCng]::new(256) } else { $ecdh = [ECDiffieHellman]::Create() } $ecdh.ImportParameters($params) return $ecdh } #endregion #region Key Exchange static [byte[]]DeriveSharedSecret([ECDiffieHellman]$MyKeyPair, [string]$TheirPublicKeyBase64) { $theirKey = [CryptoProvider]::ImportPublicKey($TheirPublicKeyBase64) $rawSecret = $null try { $rawSecret = $MyKeyPair.DeriveKeyMaterial($theirKey.PublicKey) $derivedKey = [CryptoProvider]::HkdfDerive($rawSecret, 32) return $derivedKey } finally { if ($null -ne $rawSecret) { [Array]::Clear($rawSecret, 0, $rawSecret.Length) } $theirKey.Dispose() } } static [byte[]]HkdfDerive([byte[]]$InputKeyMaterial, [int]$OutputLength) { return [CryptoProvider]::HkdfDerive($InputKeyMaterial, $OutputLength, $null, $null) } static [byte[]]HkdfDerive([byte[]]$InputKeyMaterial, [int]$OutputLength, [byte[]]$Salt, [byte[]]$Info) { if ($null -eq $Salt) { $Salt = [byte[]]::new(32) } if ($null -eq $Info) { $Info = [System.Text.Encoding]::UTF8.GetBytes([CryptoProvider]::HkdfInfo) } return [HKDF]::DeriveKey( [HashAlgorithmName]::SHA256, $InputKeyMaterial, $OutputLength, $Salt, $Info ) } #endregion #region Encryption static [byte[]]Encrypt([byte[]]$Plaintext, [byte[]]$Key) { $nonce = [byte[]]::new([CryptoProvider]::NonceSize) [RandomNumberGenerator]::Fill($nonce) $ciphertext = [byte[]]::new($Plaintext.Length) $tag = [byte[]]::new([CryptoProvider]::TagSize) $aesGcm = [AesGcm]::new($Key, [CryptoProvider]::TagSize) try { $aesGcm.Encrypt($nonce, $Plaintext, $ciphertext, $tag) $result = [byte[]]::new($nonce.Length + $tag.Length + $ciphertext.Length) [Array]::Copy($nonce, 0, $result, 0, $nonce.Length) [Array]::Copy($tag, 0, $result, $nonce.Length, $tag.Length) [Array]::Copy($ciphertext, 0, $result, $nonce.Length + $tag.Length, $ciphertext.Length) return $result } finally { $aesGcm.Dispose() [Array]::Clear($nonce, 0, $nonce.Length) } } static [byte[]]Decrypt([byte[]]$EncryptedData, [byte[]]$Key) { $nonceSz = 12 $tagSz = 16 $nonce = $EncryptedData[0..($nonceSz - 1)] $tag = $EncryptedData[$nonceSz..($nonceSz + $tagSz - 1)] $ciphertext = $EncryptedData[($nonceSz + $tagSz)..($EncryptedData.Length - 1)] $plaintext = [byte[]]::new($ciphertext.Length) $aesGcm = [AesGcm]::new($Key, $tagSz) try { $aesGcm.Decrypt($nonce, $ciphertext, $tag, $plaintext) return $plaintext } finally { $aesGcm.Dispose() } } static [string]EncryptMessage([string]$Message, [byte[]]$Key) { $plainBytes = [System.Text.Encoding]::UTF8.GetBytes($Message) try { $encrypted = [CryptoProvider]::Encrypt($plainBytes, $Key) return [Convert]::ToBase64String($encrypted) } finally { [Array]::Clear($plainBytes, 0, $plainBytes.Length) } } static [string]DecryptMessage([string]$EncryptedBase64, [byte[]]$Key) { $encrypted = [Convert]::FromBase64String($EncryptedBase64) $decrypted = [CryptoProvider]::Decrypt($encrypted, $Key) try { return [System.Text.Encoding]::UTF8.GetString($decrypted) } finally { [Array]::Clear($decrypted, 0, $decrypted.Length) } } #endregion #region Secure Memory static [void]ClearBytes([byte[]]$Data) { if ($null -ne $Data -and $Data.Length -gt 0) { [Array]::Clear($Data, 0, $Data.Length) } } static [byte[]]GetRandomBytes([int]$Length) { $bytes = [byte[]]::new($Length) [RandomNumberGenerator]::Fill($bytes) return $bytes } #endregion } # ============================================================================== # Identity - Pseudonymous and anonymous identity management # ============================================================================== enum IdentityMode { Pseudonymous Anonymous } class CryptoIdentity { [string]$Id [string]$PublicKey [IdentityMode]$Mode [DateTime]$Created [bool]$IsLoaded hidden [ECDiffieHellman]$KeyPair CryptoIdentity([IdentityMode]$Mode) { $this.Mode = $Mode $this.Created = [DateTime]::UtcNow $this.KeyPair = [CryptoProvider]::NewKeyPair() $this.PublicKey = [CryptoProvider]::ExportPublicKey($this.KeyPair) $this.Id = $this.ComputeFingerprint() $this.IsLoaded = $true } CryptoIdentity([string]$KeyJson, [IdentityMode]$Mode) { $this.Mode = $Mode $this.KeyPair = [CryptoProvider]::ImportKeyPair($KeyJson) $this.PublicKey = [CryptoProvider]::ExportPublicKey($this.KeyPair) $this.Id = $this.ComputeFingerprint() $this.IsLoaded = $true $this.Created = [DateTime]::UtcNow } hidden [string]ComputeFingerprint() { $pubKeyBytes = [Convert]::FromBase64String($this.PublicKey) $hash = [SHA256]::HashData($pubKeyBytes) return [Convert]::ToBase64String($hash).Substring(0, 16).Replace('+', '-').Replace('/', '_') } [string]GetConnectionString([string]$Endpoint) { return "$Endpoint`:$($this.PublicKey)" } static [hashtable]ParseConnectionString([string]$ConnectionString) { $parts = $ConnectionString -split ':', 3 if ($parts.Count -lt 3) { throw "Invalid connection string format. Expected: host:port:publickey" } return @{ Host = $parts[0] Port = [int]$parts[1] PublicKey = $parts[2] } } [byte[]]DeriveSharedSecret([string]$PeerPublicKey) { if (-not $this.IsLoaded) { throw "Identity not loaded - cannot derive shared secret" } return [CryptoProvider]::DeriveSharedSecret($this.KeyPair, $PeerPublicKey) } [string]Export() { if ($this.Mode -eq [IdentityMode]::Anonymous) { throw "Cannot export anonymous identity" } return [CryptoProvider]::ExportKeyPair($this.KeyPair) } [void]Dispose() { if ($null -ne $this.KeyPair) { $this.KeyPair.Dispose() $this.KeyPair = $null } $this.IsLoaded = $false } [string]GetSafetyNumber([string]$PeerPublicKey) { $keys = @($this.PublicKey, $PeerPublicKey) | Sort-Object $combined = $keys[0] + $keys[1] $bytes = [System.Text.Encoding]::UTF8.GetBytes($combined) $hash = $bytes for ($i = 0; $i -lt 5200; $i++) { $hash = [SHA256]::HashData($hash) } $numbers = @() for ($i = 0; $i -lt 12; $i++) { $chunk = [BitConverter]::ToUInt32($hash, ($i * 4) % 28) % 100000 $numbers += $chunk.ToString("D5") } return $numbers -join " " } } class IdentityManager { static [string]$VaultName = "PSCryptoChat" static [string]$SecretPrefix = "PSCryptoChat-Identity-" static [bool]IsSecretManagementAvailable() { try { $null = Get-Module -ListAvailable -Name Microsoft.PowerShell.SecretManagement return $true } catch { return $false } } static [CryptoIdentity]CreateIdentity([IdentityMode]$Mode) { return [CryptoIdentity]::new($Mode) } static [void]SaveIdentity([CryptoIdentity]$Identity, [string]$Name) { if ($Identity.Mode -eq [IdentityMode]::Anonymous) { throw "Cannot save anonymous identity" } if (-not [IdentityManager]::IsSecretManagementAvailable()) { throw "SecretManagement module not available. Install with: Install-Module Microsoft.PowerShell.SecretManagement" } $secretName = [IdentityManager]::SecretPrefix + $Name $secretData = @{ KeyData = $Identity.Export() Created = $Identity.Created.ToString('o') Mode = $Identity.Mode.ToString() } | ConvertTo-Json -Compress $secureData = ConvertTo-SecureString -String $secretData -AsPlainText -Force Set-Secret -Name $secretName -SecureStringSecret $secureData -Vault ([IdentityManager]::VaultName) -ErrorAction Stop } static [CryptoIdentity]LoadIdentity([string]$Name) { if (-not [IdentityManager]::IsSecretManagementAvailable()) { throw "SecretManagement module not available" } $secretName = [IdentityManager]::SecretPrefix + $Name $secureData = Get-Secret -Name $secretName -Vault ([IdentityManager]::VaultName) -AsPlainText -ErrorAction Stop $data = $secureData | ConvertFrom-Json $identity = [CryptoIdentity]::new($data.KeyData, [IdentityMode]::Pseudonymous) $identity.Created = [DateTime]::Parse($data.Created) return $identity } static [string[]]ListIdentities() { if (-not [IdentityManager]::IsSecretManagementAvailable()) { return @() } $secrets = Get-SecretInfo -Vault ([IdentityManager]::VaultName) -ErrorAction SilentlyContinue | Where-Object { $_.Name -like "$([IdentityManager]::SecretPrefix)*" } return $secrets | ForEach-Object { $_.Name.Replace([IdentityManager]::SecretPrefix, '') } } static [void]RemoveIdentity([string]$Name) { $secretName = [IdentityManager]::SecretPrefix + $Name Remove-Secret -Name $secretName -Vault ([IdentityManager]::VaultName) -ErrorAction Stop } } # ============================================================================== # Session Management # ============================================================================== enum SessionState { Created Handshaking Established Closing Closed } class ChatSession { [string]$SessionId [SessionState]$State [CryptoIdentity]$LocalIdentity [string]$PeerPublicKey [DateTime]$Created [DateTime]$LastActivity [int]$TimeoutSeconds hidden [byte[]]$SharedSecret hidden [System.Timers.Timer]$TimeoutTimer ChatSession([CryptoIdentity]$Identity, [int]$TimeoutSeconds) { $this.SessionId = [Guid]::NewGuid().ToString("N").Substring(0, 16) $this.LocalIdentity = $Identity $this.TimeoutSeconds = $TimeoutSeconds $this.State = [SessionState]::Created $this.Created = [DateTime]::UtcNow $this.LastActivity = $this.Created } [void]CompleteHandshake([string]$PeerPublicKey) { $this.PeerPublicKey = $PeerPublicKey $this.SharedSecret = $this.LocalIdentity.DeriveSharedSecret($PeerPublicKey) $this.State = [SessionState]::Established $this.UpdateActivity() $this.StartTimeoutTimer() } [void]UpdateActivity() { $this.LastActivity = [DateTime]::UtcNow if ($null -ne $this.TimeoutTimer) { $this.TimeoutTimer.Stop() $this.TimeoutTimer.Start() } } hidden [void]StartTimeoutTimer() { if ($this.TimeoutSeconds -le 0) { return } $this.TimeoutTimer = [System.Timers.Timer]::new($this.TimeoutSeconds * 1000) $this.TimeoutTimer.AutoReset = $false # Store session ID for closure $sid = $this.SessionId $this.TimeoutTimer.add_Elapsed({ Write-Warning "Session $sid timed out" [SessionManager]::CloseSession($sid) }) $this.TimeoutTimer.Start() } [string]Encrypt([string]$Message) { if ($this.State -ne [SessionState]::Established) { throw "Session not established" } $this.UpdateActivity() return [CryptoProvider]::EncryptMessage($Message, $this.SharedSecret) } [string]Decrypt([string]$EncryptedMessage) { if ($this.State -ne [SessionState]::Established) { throw "Session not established" } $this.UpdateActivity() return [CryptoProvider]::DecryptMessage($EncryptedMessage, $this.SharedSecret) } [hashtable]GetInfo() { return @{ SessionId = $this.SessionId State = $this.State.ToString() Created = $this.Created LastActivity = $this.LastActivity Timeout = $this.TimeoutSeconds PeerKey = if ($this.PeerPublicKey) { $this.PeerPublicKey.Substring(0, 20) + "..." } else { $null } } } [void]Close() { $this.State = [SessionState]::Closing if ($null -ne $this.TimeoutTimer) { $this.TimeoutTimer.Stop() $this.TimeoutTimer.Dispose() $this.TimeoutTimer = $null } if ($null -ne $this.SharedSecret) { [CryptoProvider]::ClearBytes($this.SharedSecret) $this.SharedSecret = $null } $this.State = [SessionState]::Closed } } class SessionManager { static [hashtable]$Sessions = @{} static [ChatSession]CreateSession([CryptoIdentity]$Identity, [int]$TimeoutSeconds) { $session = [ChatSession]::new($Identity, $TimeoutSeconds) [SessionManager]::Sessions[$session.SessionId] = $session return $session } static [ChatSession]GetSession([string]$SessionId) { return [SessionManager]::Sessions[$SessionId] } static [void]CloseSession([string]$SessionId) { $session = [SessionManager]::Sessions[$SessionId] if ($null -ne $session) { $session.Close() [SessionManager]::Sessions.Remove($SessionId) } } static [void]CloseAllSessions() { foreach ($sid in @([SessionManager]::Sessions.Keys)) { [SessionManager]::CloseSession($sid) } } } # ============================================================================== # Transport Layer - UDP Communication # ============================================================================== class UdpTransport { [int]$LocalPort [string]$RemoteHost [int]$RemotePort [bool]$IsListening [System.Net.IPEndPoint]$LastReceivedFrom hidden [System.Net.Sockets.UdpClient]$Client hidden [System.Threading.CancellationTokenSource]$CancelToken UdpTransport([int]$Port) { $this.LocalPort = $Port $this.IsListening = $false } [void]Start() { if ($this.LocalPort -eq 0) { $this.Client = [System.Net.Sockets.UdpClient]::new() $this.Client.Client.Bind([System.Net.IPEndPoint]::new([System.Net.IPAddress]::Any, 0)) $this.LocalPort = ([System.Net.IPEndPoint]$this.Client.Client.LocalEndPoint).Port } else { $this.Client = [System.Net.Sockets.UdpClient]::new($this.LocalPort) } $this.CancelToken = [System.Threading.CancellationTokenSource]::new() $this.IsListening = $true } [void]Connect([string]$HostName, [int]$Port) { $this.RemoteHost = $HostName $this.RemotePort = $Port $this.Client.Connect($HostName, $Port) } [void]SendBytes([byte[]]$Data) { if ($null -eq $this.Client) { throw "Transport not started" } if ($this.RemoteHost) { # Connected mode - use the connected endpoint $null = $this.Client.Send($Data, $Data.Length) } elseif ($null -ne $this.LastReceivedFrom) { # Host mode - reply to the last received endpoint $null = $this.Client.Send($Data, $Data.Length, $this.LastReceivedFrom) } else { throw "Not connected to remote host" } } [void]SendString([string]$Message) { $bytes = [System.Text.Encoding]::UTF8.GetBytes($Message) $this.SendBytes($bytes) } [byte[]]ReceiveBytes([int]$TimeoutMs) { if ($null -eq $this.Client) { throw "Transport not started" } $endpoint = [System.Net.IPEndPoint]::new([System.Net.IPAddress]::Any, 0) $this.Client.Client.ReceiveTimeout = $TimeoutMs try { $data = $this.Client.Receive([ref]$endpoint) # Store the sender's endpoint for reply (enables host to send back) $this.LastReceivedFrom = $endpoint return $data } catch [System.Net.Sockets.SocketException] { if ($_.Exception.SocketErrorCode -eq [System.Net.Sockets.SocketError]::TimedOut) { return $null } throw } } [string]ReceiveString([int]$TimeoutMs) { $bytes = $this.ReceiveBytes($TimeoutMs) if ($null -eq $bytes) { return $null } return [System.Text.Encoding]::UTF8.GetString($bytes) } [string]GetLocalEndpointString() { if ($null -eq $this.Client) { return "0.0.0.0:0" } # Get local IP (not 0.0.0.0) $localIp = [System.Net.Dns]::GetHostAddresses([System.Net.Dns]::GetHostName()) | Where-Object { $_.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork } | Select-Object -First 1 if ($null -eq $localIp) { $localIp = [System.Net.IPAddress]::Loopback } return "$($localIp.ToString()):$($this.LocalPort)" } [void]Stop() { $this.IsListening = $false if ($null -ne $this.CancelToken) { $this.CancelToken.Cancel() $this.CancelToken.Dispose() $this.CancelToken = $null } if ($null -ne $this.Client) { $this.Client.Close() $this.Client.Dispose() $this.Client = $null } } } # ============================================================================== # Message Protocol # ============================================================================== class MessageProtocol { static [string]$Version = "1.0" static [string]CreateHandshake([string]$PublicKey, [string]$SessionId) { $msg = @{ type = "handshake" version = [MessageProtocol]::Version publicKey = $PublicKey sessionId = $SessionId timestamp = [DateTime]::UtcNow.ToString('o') } return ($msg | ConvertTo-Json -Compress) } static [string]CreateMessage([string]$EncryptedContent) { $msg = @{ type = "message" version = [MessageProtocol]::Version content = $EncryptedContent timestamp = [DateTime]::UtcNow.ToString('o') } return ($msg | ConvertTo-Json -Compress) } static [string]CreateAck([string]$MessageId) { $msg = @{ type = "ack" version = [MessageProtocol]::Version messageId = $MessageId timestamp = [DateTime]::UtcNow.ToString('o') } return ($msg | ConvertTo-Json -Compress) } static [string]CreateDisconnect([string]$Reason) { $msg = @{ type = "disconnect" version = [MessageProtocol]::Version reason = $Reason timestamp = [DateTime]::UtcNow.ToString('o') } return ($msg | ConvertTo-Json -Compress) } static [hashtable]Parse([string]$Message) { try { return ($Message | ConvertFrom-Json -AsHashtable) } catch { return @{ type = "unknown"; raw = $Message } } } } # ============================================================================== # Manual Discovery (Connection Strings) # ============================================================================== class ManualDiscovery { static [hashtable]ParseConnectionString([string]$ConnectionString) { # Format: host:port:base64publickey $parts = $ConnectionString -split ':', 3 if ($parts.Count -lt 3) { throw "Invalid connection string format. Expected: host:port:publickey" } return @{ Host = $parts[0] Port = [int]$parts[1] PublicKey = $parts[2] } } static [string]CreateConnectionString([string]$HostName, [int]$Port, [string]$PublicKey) { return "${HostName}:${Port}:${PublicKey}" } } # ============================================================================== # mDNS Discovery (Placeholder - full implementation pending) # ============================================================================== class PeerDiscovery { [bool]$IsRunning hidden [bool]$UseMdns hidden [System.Collections.ArrayList]$DiscoveredPeers PeerDiscovery([bool]$UseMdns) { $this.UseMdns = $UseMdns $this.DiscoveredPeers = [System.Collections.ArrayList]::new() $this.IsRunning = $false } [void]Start() { $this.IsRunning = $true # mDNS implementation would go here } [void]Stop() { $this.IsRunning = $false } [void]Announce([string]$SessionId, [int]$Port, [string]$PublicKey) { # mDNS announce implementation Write-Verbose "Announcing session $SessionId on port $Port" } [System.Collections.ArrayList]FindPeers([int]$TimeoutMs) { # mDNS browse implementation would go here # For now, return empty list return $this.DiscoveredPeers } } #endregion # Module-level variables $script:ModuleRoot = $PSScriptRoot $script:ActiveSessions = @{} $script:CurrentIdentity = $null # Import public functions (classes are already defined above) . "$PSScriptRoot\Public\Identity.ps1" . "$PSScriptRoot\Public\Session.ps1" . "$PSScriptRoot\Public\Messaging.ps1" . "$PSScriptRoot\Public\Discovery.ps1" # Module initialization Write-Verbose "PSCryptoChat module loaded from $PSScriptRoot" |