diff --git a/src/Crypto/MlKem768OpenSsl.php b/src/Crypto/MlKem768OpenSsl.php new file mode 100644 index 0000000..b754c39 --- /dev/null +++ b/src/Crypto/MlKem768OpenSsl.php @@ -0,0 +1,193 @@ +supportsAlgorithm(); + } catch (Throwable) { + self::$available = false; + } + + return self::$available; + } + + public static function create(): self + { + $backend = new self; + if (! $backend->supportsAlgorithm()) { + throw new RuntimeException('OpenSSL does not provide ML-KEM-768.'); + } + + return $backend; + } + + private function __construct() + { + if (! extension_loaded('ffi')) { + throw new RuntimeException('FFI extension not loaded.'); + } + + $this->ffi = FFI::cdef(' + typedef struct evp_pkey_st EVP_PKEY; + typedef struct evp_pkey_ctx_st EVP_PKEY_CTX; + typedef struct ossl_lib_ctx_st OSSL_LIB_CTX; + + EVP_PKEY_CTX *EVP_PKEY_CTX_new_from_name(OSSL_LIB_CTX *libctx, const char *name, const char *propquery); + EVP_PKEY *EVP_PKEY_new_raw_public_key_ex(OSSL_LIB_CTX *libctx, const char *keytype, const char *propq, const unsigned char *key, size_t keylen); + EVP_PKEY_CTX *EVP_PKEY_CTX_new_from_pkey(OSSL_LIB_CTX *libctx, EVP_PKEY *pkey, const char *propquery); + + int EVP_PKEY_encapsulate_init(EVP_PKEY_CTX *ctx, const void *params); + int EVP_PKEY_encapsulate(EVP_PKEY_CTX *ctx, unsigned char *wrappedkey, size_t *wrappedkeylen, unsigned char *genkey, size_t *genkeylen); + + void EVP_PKEY_CTX_free(EVP_PKEY_CTX *ctx); + void EVP_PKEY_free(EVP_PKEY *pkey); + + unsigned long ERR_get_error(void); + char *ERR_error_string(unsigned long e, char *buf); + ', $this->libcrypto()); + } + + /** + * @return array{ciphertext: string, sharedSecret: string} + */ + public function encapsulate(string $publicKey): array + { + if (strlen($publicKey) !== self::PUBLIC_KEY_BYTES) { + throw new RuntimeException('Invalid ML-KEM-768 public key length.'); + } + + $publicKeyBuffer = $this->ffi->new('unsigned char['.self::PUBLIC_KEY_BYTES.']'); + FFI::memcpy($publicKeyBuffer, $publicKey, self::PUBLIC_KEY_BYTES); + + $publicKeyHandle = $this->ffi->EVP_PKEY_new_raw_public_key_ex( + null, + self::ALGORITHM, + null, + $publicKeyBuffer, + self::PUBLIC_KEY_BYTES, + ); + + if ($publicKeyHandle === null) { + throw new RuntimeException('Could not import ML-KEM-768 public key: '.$this->lastError()); + } + + $context = null; + try { + $context = $this->ffi->EVP_PKEY_CTX_new_from_pkey(null, $publicKeyHandle, null); + if ($context === null) { + throw new RuntimeException('Could not create ML-KEM-768 context: '.$this->lastError()); + } + + if ($this->ffi->EVP_PKEY_encapsulate_init($context, null) !== 1) { + throw new RuntimeException('Could not initialize ML-KEM-768 encapsulation: '.$this->lastError()); + } + + $ciphertextLength = $this->ffi->new('size_t[1]'); + $sharedSecretLength = $this->ffi->new('size_t[1]'); + if ($this->ffi->EVP_PKEY_encapsulate($context, null, $ciphertextLength, null, $sharedSecretLength) !== 1) { + throw new RuntimeException('Could not size ML-KEM-768 encapsulation output: '.$this->lastError()); + } + + if ($ciphertextLength[0] !== self::CIPHERTEXT_BYTES || $sharedSecretLength[0] !== self::SHARED_SECRET_BYTES) { + throw new RuntimeException('Unexpected ML-KEM-768 encapsulation output length.'); + } + + $ciphertext = $this->ffi->new('unsigned char['.self::CIPHERTEXT_BYTES.']'); + $sharedSecret = $this->ffi->new('unsigned char['.self::SHARED_SECRET_BYTES.']'); + + if ($this->ffi->EVP_PKEY_encapsulate($context, $ciphertext, $ciphertextLength, $sharedSecret, $sharedSecretLength) !== 1) { + throw new RuntimeException('Could not encapsulate ML-KEM-768 shared secret: '.$this->lastError()); + } + + return [ + 'ciphertext' => FFI::string($ciphertext, self::CIPHERTEXT_BYTES), + 'sharedSecret' => FFI::string($sharedSecret, self::SHARED_SECRET_BYTES), + ]; + } finally { + if ($context !== null) { + $this->ffi->EVP_PKEY_CTX_free($context); + } + + $this->ffi->EVP_PKEY_free($publicKeyHandle); + } + } + + private function supportsAlgorithm(): bool + { + $context = $this->ffi->EVP_PKEY_CTX_new_from_name(null, self::ALGORITHM, null); + if ($context === null) { + return false; + } + + $this->ffi->EVP_PKEY_CTX_free($context); + + return true; + } + + private function lastError(): string + { + $error = $this->ffi->ERR_get_error(); + if ($error === 0) { + return 'no OpenSSL error available'; + } + + return FFI::string($this->ffi->ERR_error_string($error, null)); + } + + private function libcrypto(): string + { + $configured = getenv('WHISP_LIBCRYPTO'); + if (is_string($configured) && $configured !== '') { + return $configured; + } + + $candidates = PHP_OS === 'Darwin' + ? [ + '/opt/homebrew/opt/openssl@3/lib/libcrypto.dylib', + '/usr/local/opt/openssl@3/lib/libcrypto.dylib', + 'libcrypto.dylib', + ] + : [ + 'libcrypto.so.3', + 'libcrypto.so', + ]; + + foreach ($candidates as $candidate) { + try { + FFI::cdef('unsigned long OpenSSL_version_num(void);', $candidate); + + return $candidate; + } catch (Throwable) { + continue; + } + } + + throw new RuntimeException('Could not load libcrypto.'); + } +} diff --git a/src/Kex.php b/src/Kex.php index 0b57fb1..ca2c25e 100644 --- a/src/Kex.php +++ b/src/Kex.php @@ -5,11 +5,17 @@ namespace Whisp; use Psr\Log\LoggerInterface; +use RuntimeException; +use Whisp\Crypto\MlKem768OpenSsl; use Whisp\Enums\MessageType; use Whisp\Loggers\NullLogger; class Kex { + public const KEX_CURVE25519_SHA256 = 'curve25519-sha256'; + + public const KEX_MLKEM768X25519_SHA256 = 'mlkem768x25519-sha256'; + public string $serverKexInit; public string $sharedSecret; @@ -18,6 +24,8 @@ class Kex public string $exchangeHash; + public bool $sharedSecretIsString = false; + private ?LoggerInterface $logger; /** @@ -37,6 +45,21 @@ public function __construct( * Diffie Hellman key exchange response */ public function response(): string + { + return match ($this->kexNegotiator->selectedKexAlgorithm) { + self::KEX_MLKEM768X25519_SHA256 => $this->mlKem768X25519Response(), + default => $this->curve25519Response(), + }; + } + + public function encodedSharedSecret(): string + { + return $this->sharedSecretIsString + ? $this->packString($this->sharedSecret) + : $this->packMpint($this->sharedSecret); + } + + private function curve25519Response(): string { // Extract client's public key (32 bytes after 4-byte length) $clientPublicKeyLength = unpack('N', substr($this->packet->message, 0, 4))[1]; @@ -96,6 +119,72 @@ public function response(): string return $kexReplyPayload; } + private function mlKem768X25519Response(): string + { + $clientBlobLength = unpack('N', substr($this->packet->message, 0, 4))[1]; + $clientBlob = substr($this->packet->message, 4, $clientBlobLength); + + $expectedClientBlobLength = MlKem768OpenSsl::PUBLIC_KEY_BYTES + 32; + if ($clientBlobLength !== $expectedClientBlobLength || strlen($clientBlob) !== $expectedClientBlobLength) { + throw new RuntimeException('Invalid mlkem768x25519-sha256 client key exchange payload.'); + } + + $clientMlKemPublicKey = substr($clientBlob, 0, MlKem768OpenSsl::PUBLIC_KEY_BYTES); + $clientCurve25519PublicKey = substr($clientBlob, MlKem768OpenSsl::PUBLIC_KEY_BYTES, 32); + + $mlKem = MlKem768OpenSsl::create(); + $encapsulation = $mlKem->encapsulate($clientMlKemPublicKey); + + $curveKeyPair = sodium_crypto_box_keypair(); + $curve25519Private = sodium_crypto_box_secretkey($curveKeyPair); + $curve25519Public = sodium_crypto_box_publickey($curveKeyPair); + $curve25519SharedSecret = sodium_crypto_scalarmult($curve25519Private, $clientCurve25519PublicKey); + + $serverBlob = $encapsulation['ciphertext'].$curve25519Public; + $this->sharedSecret = hash('sha256', $encapsulation['sharedSecret'].$curve25519SharedSecret, true); + $this->sharedSecretIsString = true; + + $ed25519Private = $this->serverHostKey->getPrivateKey(); + $ed25519Public = $this->serverHostKey->getPublicKey(); + $hostKeyBlob = $this->packString('ssh-ed25519').$this->packString($ed25519Public); + + $exchangeHash = hash('sha256', implode('', [ + $this->packString($this->kexNegotiator->clientVersion), + $this->packString($this->kexNegotiator->serverVersion), + $this->packString($this->kexNegotiator->clientKexInit), + $this->packString($this->kexNegotiator->serverKexInit), + $this->packString($hostKeyBlob), + $this->packString($clientBlob), + $this->packString($serverBlob), + $this->packString($this->sharedSecret), + ]), true); + + if (is_null($this->sessionId)) { + $this->sessionId = $exchangeHash; + } + + $signature = sodium_crypto_sign_detached($exchangeHash, $ed25519Private); + $signatureBlob = $this->packString('ssh-ed25519').$this->packString($signature); + + $this->logger->debug('Hybrid key exchange details:'.print_r([ + 'algorithm' => self::KEX_MLKEM768X25519_SHA256, + 'host_key_blob_len' => strlen($hostKeyBlob), + 'client_blob_len' => strlen($clientBlob), + 'server_blob_len' => strlen($serverBlob), + 'exchange_hash' => bin2hex($exchangeHash), + ], true)); + + $kexReplyPayload = + MessageType::chr(MessageType::KEXDH_REPLY). + $this->packString($hostKeyBlob). + $this->packString($serverBlob). + $this->packString($signatureBlob); + + $this->exchangeHash = $exchangeHash; + + return $kexReplyPayload; + } + private function packString(string $str): string { return pack('N', strlen($str)).$str; diff --git a/src/KexNegotiator.php b/src/KexNegotiator.php index 984f792..326d4ee 100644 --- a/src/KexNegotiator.php +++ b/src/KexNegotiator.php @@ -4,6 +4,7 @@ namespace Whisp; +use Whisp\Crypto\MlKem768OpenSsl; use Whisp\Enums\MessageType; class KexNegotiator @@ -12,10 +13,9 @@ class KexNegotiator public ?string $serverKexInit = null; - private array $kexAlgorithms = [ - 'curve25519-sha256', // Most modern and recommended - // 'ecdh-sha2-nistp256', // Widely supported backup - ]; + public string $selectedKexAlgorithm = Kex::KEX_CURVE25519_SHA256; + + private array $kexAlgorithms; private array $serverHostKeyAlgorithms = [ 'ssh-ed25519', // Modern, secure, and efficient @@ -49,11 +49,13 @@ public function __construct( public string $clientVersion, public string $serverVersion, ) { + $this->kexAlgorithms = $this->availableKexAlgorithms(); } public function response(): string { $this->clientKexInit = chr($this->packet->type->value).$this->packet->message; + $this->selectedKexAlgorithm = $this->negotiateKexAlgorithm(); // Build our algorithms lists $kexAlgorithms = implode(',', $this->kexAlgorithms); @@ -93,4 +95,55 @@ private function packString(string $str): string { return pack('N', strlen($str)).$str; } + + private function availableKexAlgorithms(): array + { + $algorithms = []; + + if (MlKem768OpenSsl::isAvailable()) { + $algorithms[] = Kex::KEX_MLKEM768X25519_SHA256; + } + + $algorithms[] = Kex::KEX_CURVE25519_SHA256; + + return $algorithms; + } + + private function negotiateKexAlgorithm(): string + { + foreach ($this->clientKexAlgorithms() as $algorithm) { + if (in_array($algorithm, $this->kexAlgorithms, true)) { + return $algorithm; + } + } + + return Kex::KEX_CURVE25519_SHA256; + } + + private function clientKexAlgorithms(): array + { + $offset = 16; // SSH_MSG_KEXINIT cookie + $nameList = $this->readString($this->packet->message, $offset); + + return $nameList === '' ? [] : explode(',', $nameList); + } + + private function readString(string $payload, int &$offset): string + { + if (strlen($payload) < $offset + 4) { + return ''; + } + + $length = unpack('N', substr($payload, $offset, 4))[1]; + $offset += 4; + + if (strlen($payload) < $offset + $length) { + return ''; + } + + $value = substr($payload, $offset, $length); + $offset += $length; + + return $value; + } } diff --git a/src/PacketHandler.php b/src/PacketHandler.php index 5d09427..7933e94 100644 --- a/src/PacketHandler.php +++ b/src/PacketHandler.php @@ -82,8 +82,7 @@ public function deriveKeys(?Kex $kexToUse = null): void $this->rekeyKex = $kex; } - // Pack shared secret as MPInt (confirm no extra leading zeros) - $K = $this->packMpint($kex->sharedSecret); + $K = $kex->encodedSharedSecret(); $H = $kex->exchangeHash; // Modified KDF to support extended hashing if needed diff --git a/tests/Unit/KexNegotiatorTest.php b/tests/Unit/KexNegotiatorTest.php new file mode 100644 index 0000000..d8aba3d --- /dev/null +++ b/tests/Unit/KexNegotiatorTest.php @@ -0,0 +1,61 @@ + pack('N', strlen(implode(',', $names))).implode(',', $names); + + $payload = chr(MessageType::KEXINIT->value) + .str_repeat("\0", 16) + .$nameList($kexAlgorithms) + .$nameList(['ssh-ed25519']) + .$nameList(['aes256-gcm@openssh.com']) + .$nameList(['aes256-gcm@openssh.com']) + .$nameList(['hmac-sha2-256']) + .$nameList(['hmac-sha2-256']) + .$nameList(['none']) + .$nameList(['none']) + .$nameList([]) + .$nameList([]) + ."\0" + .pack('N', 0); + + return new Packet($payload); +} + +test('selects mlkem768x25519 when OpenSSL supports ML-KEM-768', function () { + if (! MlKem768OpenSsl::isAvailable()) { + $this->markTestSkipped('OpenSSL does not provide ML-KEM-768.'); + } + + $negotiator = new KexNegotiator( + kexinit_packet([Kex::KEX_MLKEM768X25519_SHA256, Kex::KEX_CURVE25519_SHA256]), + 'SSH-2.0-test-client', + 'SSH-2.0-test-server', + ); + + $response = $negotiator->response(); + + expect($negotiator->selectedKexAlgorithm)->toBe(Kex::KEX_MLKEM768X25519_SHA256) + ->and($response)->toContain(Kex::KEX_MLKEM768X25519_SHA256); +}); + +test('keeps curve25519 fallback available', function () { + $negotiator = new KexNegotiator( + kexinit_packet([Kex::KEX_CURVE25519_SHA256]), + 'SSH-2.0-test-client', + 'SSH-2.0-test-server', + ); + + $response = $negotiator->response(); + + expect($negotiator->selectedKexAlgorithm)->toBe(Kex::KEX_CURVE25519_SHA256) + ->and($response)->toContain(Kex::KEX_CURVE25519_SHA256); +});