Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions src/Crypto/MlKem768OpenSsl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
<?php

declare(strict_types=1);

namespace Whisp\Crypto;

use FFI;
use RuntimeException;
use Throwable;

final class MlKem768OpenSsl
{
public const PUBLIC_KEY_BYTES = 1184;

public const CIPHERTEXT_BYTES = 1088;

public const SHARED_SECRET_BYTES = 32;

private const ALGORITHM = 'ML-KEM-768';

private static ?bool $available = null;

private FFI $ffi;

public static function isAvailable(): bool
{
if (self::$available !== null) {
return self::$available;
}

try {
self::$available = (new self)->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.');
}
}
89 changes: 89 additions & 0 deletions src/Kex.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,6 +24,8 @@ class Kex

public string $exchangeHash;

public bool $sharedSecretIsString = false;

private ?LoggerInterface $logger;

/**
Expand All @@ -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];
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading