Skip to content

Instantly share code, notes, and snippets.

@azjezz
Created March 3, 2026 22:46
Show Gist options
  • Select an option

  • Save azjezz/adcb0cefc7b5a51103f6cdb2bb77f497 to your computer and use it in GitHub Desktop.

Select an option

Save azjezz/adcb0cefc7b5a51103f6cdb2bb77f497 to your computer and use it in GitHub Desktop.
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use Psl\Ansi;
use Psl\Ansi\Color;
use Psl\Ansi\Style;
use Psl\Async;
use Psl\Binary;
use Psl\Channel;
use Psl\Crypto\KeyExchange as CryptoKeyExchange;
use Psl\Crypto\Signing;
use Psl\Crypto\StreamCipher;
use Psl\DateTime;
use Psl\Encoding\Hex;
use Psl\Hash;
use Psl\IO;
use Psl\Iter;
use Psl\Network;
use Psl\SecureRandom;
use Psl\Str;
use Psl\Str\Byte;
use Psl\TCP;
use Psl\Terminal;
use Psl\Terminal\Event;
use Psl\Terminal\Layout;
use Psl\Terminal\Widget;
const SSH_VERSION = 'SSH-2.0-PslSSH_1.0';
const SSH_HOST = '127.0.0.1';
const SSH_PORT = 2222;
const SSH_MSG_SERVICE_REQUEST = 5;
const SSH_MSG_SERVICE_ACCEPT = 6;
const SSH_MSG_KEXINIT = 20;
const SSH_MSG_NEWKEYS = 21;
const SSH_MSG_KEX_ECDH_INIT = 30;
const SSH_MSG_KEX_ECDH_REPLY = 31;
const SSH_MSG_EXT_INFO = 7;
const SSH_MSG_USERAUTH_REQUEST = 50;
const SSH_MSG_USERAUTH_FAILURE = 51;
const SSH_MSG_USERAUTH_SUCCESS = 52;
const SSH_MSG_USERAUTH_PK_OK = 60;
const SSH_MSG_CHANNEL_OPEN = 90;
const SSH_MSG_CHANNEL_OPEN_CONFIRMATION = 91;
const SSH_MSG_CHANNEL_WINDOW_ADJUST = 93;
const SSH_MSG_CHANNEL_DATA = 94;
const SSH_MSG_CHANNEL_EOF = 96;
const SSH_MSG_CHANNEL_CLOSE = 97;
const SSH_MSG_CHANNEL_REQUEST = 98;
const SSH_MSG_CHANNEL_SUCCESS = 99;
const SSH_KEX_ALGORITHM = 'curve25519-sha256';
const SSH_HOST_KEY_ALGORITHM = 'ssh-ed25519';
const SSH_CIPHER = 'aes256-ctr';
const SSH_MAC = 'hmac-sha2-256';
const SSH_COMPRESSION = 'none';
const SSH_INITIAL_WINDOW_SIZE = 1_048_576;
const SSH_MAX_PACKET_SIZE = 32_768;
const SSH_MAX_CHANNEL_DATA = 16_384;
const SSH_CIPHER_BLOCK_SIZE = 16;
const SSH_MIN_PADDING = 4;
const SSH_MAX_PACKET_READ = 35_000;
const SSH_KEY_SIZE = 32;
const SSH_IV_SIZE = 16;
const SSH_MAC_SIZE = 32;
const SSH_AUTH_ALGORITHMS = [
'ssh-ed25519',
'ssh-rsa',
'rsa-sha2-256',
'rsa-sha2-512',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
];
final class CryptoState
{
public int $sequence_number = 0;
public readonly StreamCipher\Context $cipher;
/**
* @param non-empty-string $mac_key
* @param non-empty-string $encryption_key
*/
public function __construct(
public readonly string $mac_key,
string $encryption_key,
string $iv,
) {
$this->cipher = new StreamCipher\Context(
new StreamCipher\Key($encryption_key),
$iv,
StreamCipher\Algorithm::Aes256Ctr,
);
}
}
final class TransportState
{
public null|CryptoState $encryptor = null;
public null|CryptoState $decryptor = null;
public int $send_sequence = 0;
public int $recv_sequence = 0;
public readonly IO\Reader $reader;
public function __construct(
public readonly Network\StreamInterface $stream,
) {
$this->reader = new IO\Reader($stream);
}
}
final readonly class ChatMessage
{
public function __construct(
public string $sender,
public string $text,
public bool $system = false,
) {}
}
final readonly class ChatUser
{
public function __construct(
public int $id,
public string $name,
public Channel\SenderInterface $sender,
) {}
}
final class ChatRoom
{
/** @var array<int, ChatUser> */
public array $users = [];
/** @var array<int, Terminal\Application> */
public array $apps = [];
public int $next_id = 0;
}
final class ChatState
{
public string $input_value = '';
/** @var non-negative-int */
public int $input_cursor = 0;
/** @var list<ChatMessage> */
public array $messages = [];
public string $user_name = '';
/** @var list<string> */
public array $online_users = [];
public int $frame_count = 0;
public DateTime\Timestamp $last_fps_time;
public int $fps = 0;
}
function crypto_compute_mac(CryptoState $state, string $packet): string
{
$seq_bytes = new Binary\Writer()->u32($state->sequence_number)->toString();
$hex = Hash\Hmac\hash($seq_bytes . $packet, Hash\Hmac\Algorithm::Sha256, $state->mac_key);
return Hex\decode($hex);
}
function crypto_encrypt(CryptoState $state, string $packet): string
{
$mac = crypto_compute_mac($state, $packet);
$encrypted = $state->cipher->apply($packet);
$state->sequence_number++;
return $encrypted . $mac;
}
function crypto_decrypt_remaining(
CryptoState $state,
string $decrypted_length,
string $encrypted_rest,
string $mac,
): string {
$decrypted_rest = $state->cipher->apply($encrypted_rest);
$full_packet = $decrypted_length . $decrypted_rest;
$expected_mac = crypto_compute_mac($state, $full_packet);
if (!Hash\equals($expected_mac, $mac)) {
throw new RuntimeException('MAC verification failed');
}
$state->sequence_number++;
return $full_packet;
}
/**
* @param string $shared_secret
* @param string $exchange_hash
* @param string $session_id
*
* @return array{
* client_to_server_iv: non-empty-string,
* server_to_client_iv: non-empty-string,
* client_to_server_key: non-empty-string,
* server_to_client_key: non-empty-string,
* client_to_server_mac: non-empty-string,
* server_to_client_mac: non-empty-string,
* }
*/
function crypto_derive_keys(
#[SensitiveParameter] string $shared_secret,
string $exchange_hash,
string $session_id,
): array {
return [
'client_to_server_iv' => crypto_derive_key(
$shared_secret,
$exchange_hash,
letter: 'A',
session_id: $session_id,
needed: SSH_IV_SIZE,
),
'server_to_client_iv' => crypto_derive_key(
$shared_secret,
$exchange_hash,
letter: 'B',
session_id: $session_id,
needed: SSH_IV_SIZE,
),
'client_to_server_key' => crypto_derive_key(
$shared_secret,
$exchange_hash,
letter: 'C',
session_id: $session_id,
needed: SSH_KEY_SIZE,
),
'server_to_client_key' => crypto_derive_key(
$shared_secret,
$exchange_hash,
letter: 'D',
session_id: $session_id,
needed: SSH_KEY_SIZE,
),
'client_to_server_mac' => crypto_derive_key(
$shared_secret,
$exchange_hash,
letter: 'E',
session_id: $session_id,
needed: SSH_MAC_SIZE,
),
'server_to_client_mac' => crypto_derive_key(
$shared_secret,
$exchange_hash,
letter: 'F',
session_id: $session_id,
needed: SSH_MAC_SIZE,
),
];
}
/**
* @param non-negative-int $needed
* @return non-empty-string
*/
function crypto_derive_key(string $k, string $h, string $letter, string $session_id, int $needed): string
{
$key = Hex\decode(Hash\hash($k . $h . $letter . $session_id, Hash\Algorithm::Sha256));
while (Byte\length($key) < $needed) {
$key .= Hex\decode(Hash\hash($k . $h . $key, Hash\Algorithm::Sha256));
}
/** @var non-empty-string */
return Byte\slice($key, offset: 0, length: $needed);
}
function crypto_encode_mpint(string $bytes): string
{
$bytes = Byte\trim_left($bytes, char_mask: "\x00");
if ($bytes === '') {
$bytes = "\x00";
}
if (Byte\ord($bytes[0]) & 0x80) {
$bytes = "\x00" . $bytes;
}
return new Binary\Writer()
->u32PrefixedBytes($bytes)
->toString();
}
function transport_read_line(TransportState $t): string
{
$line = $t->reader->readUntil("\r\n");
if ($line === null) {
throw new RuntimeException('Connection closed before version exchange');
}
return $line;
}
function transport_read_packet(TransportState $t): string
{
if ($t->decryptor !== null) {
$encrypted_length = $t->reader->readFixedSize(4);
$decrypted_length = $t->decryptor->cipher->apply($encrypted_length);
$packet_length = new Binary\Reader($decrypted_length)->u32();
if ($packet_length === 0) {
throw new RuntimeException('Connection closed before version exchange');
}
if ($packet_length > SSH_MAX_PACKET_READ) {
throw new RuntimeException("Packet too large: {$packet_length}");
}
$encrypted_rest = $t->reader->readFixedSize($packet_length);
$mac = $t->reader->readFixedSize(SSH_MAC_SIZE);
$full_packet = crypto_decrypt_remaining($t->decryptor, $decrypted_length, $encrypted_rest, $mac);
$padding_length = Byte\ord($full_packet[4]);
/** @var non-negative-int $packet_length */
$packet_length = $packet_length - $padding_length - 1;
return Byte\slice($full_packet, offset: 5, length: $packet_length);
}
$header = $t->reader->readFixedSize(4);
$packet_length = new Binary\Reader($header)->u32();
if ($packet_length === 0) {
throw new RuntimeException('Connection closed before version exchange');
}
if ($packet_length > SSH_MAX_PACKET_READ) {
throw new RuntimeException("Packet too large: {$packet_length}");
}
$data = $t->reader->readFixedSize($packet_length);
$padding_length = Byte\ord($data[0]);
$t->recv_sequence++;
/** @var non-negative-int $data_length */
$data_length = $packet_length - $padding_length - 1;
return Byte\slice($data, offset: 1, length: $data_length);
}
function transport_write_packet(TransportState $t, string $payload): void
{
$payload_length = Byte\length($payload);
$block_size = $t->encryptor !== null ? SSH_CIPHER_BLOCK_SIZE : 8;
$min_size = 1 + $payload_length + SSH_MIN_PADDING;
$padding = $block_size - ($min_size % $block_size);
if ($padding < SSH_MIN_PADDING) {
$padding += $block_size;
}
$packet = new Binary\Writer()
->u32(1 + $payload_length + $padding)
->u8($padding)
->bytes($payload)
->bytes(SecureRandom\bytes($padding))
->toString();
if ($t->encryptor !== null) {
$t->stream->writeAll(crypto_encrypt($t->encryptor, $packet));
return;
}
$t->stream->writeAll($packet);
$t->send_sequence++;
}
/**
* @return array{public: non-empty-string, secret: non-empty-string}
*/
function host_key_generate(): array
{
$kp = Signing\generate_key_pair();
return ['public' => $kp->publicKey->bytes, 'secret' => $kp->secretKey->bytes];
}
function kex_build_init(): string
{
return new Binary\Writer()
->u8(SSH_MSG_KEXINIT)
->bytes(SecureRandom\bytes(16))
->u32PrefixedBytes(SSH_KEX_ALGORITHM)
->u32PrefixedBytes(SSH_HOST_KEY_ALGORITHM)
->u32PrefixedBytes(SSH_CIPHER)
->u32PrefixedBytes(SSH_CIPHER)
->u32PrefixedBytes(SSH_MAC)
->u32PrefixedBytes(SSH_MAC)
->u32PrefixedBytes(SSH_COMPRESSION)
->u32PrefixedBytes(SSH_COMPRESSION)
->u32PrefixedBytes('')
->u32PrefixedBytes('')
->u8(0)
->u32(0)
->toString();
}
function kex_send_init(TransportState $t): string
{
$payload = kex_build_init();
transport_write_packet($t, $payload);
return $payload;
}
function kex_receive_init(TransportState $t): string
{
$payload = transport_read_packet($t);
if (Byte\ord($payload[0]) !== SSH_MSG_KEXINIT) {
throw new RuntimeException('Expected KEXINIT, got message type ' . Byte\ord($payload[0]));
}
return $payload;
}
function kex_compute_exchange_hash(
string $client_version,
string $server_version,
string $client_kex_payload,
string $server_kex_payload,
string $host_key_blob,
string $client_ephemeral,
string $server_ephemeral,
string $shared_secret_mpint,
): string {
$data = new Binary\Writer()
->u32PrefixedBytes($client_version)
->u32PrefixedBytes($server_version)
->u32PrefixedBytes($client_kex_payload)
->u32PrefixedBytes($server_kex_payload)
->u32PrefixedBytes($host_key_blob)
->u32PrefixedBytes($client_ephemeral)
->u32PrefixedBytes($server_ephemeral)
->bytes($shared_secret_mpint)
->toString();
return Hex\decode(Hash\hash($data, Hash\Algorithm::Sha256));
}
/**
* @param array{public: non-empty-string, secret: non-empty-string} $host_key
*
* @return array{exchange_hash: string, shared_secret_mpint: string}
*/
function kex_server_exchange(
TransportState $t,
array $host_key,
string $client_version,
string $server_version,
string $client_kex_payload,
string $server_kex_payload,
): array {
$payload = transport_read_packet($t);
if (Byte\ord($payload[0]) !== SSH_MSG_KEX_ECDH_INIT) {
throw new RuntimeException('Expected KEX_ECDH_INIT, got message type ' . Byte\ord($payload[0]));
}
/** @var non-empty-string $client_ephemeral */
$client_ephemeral = new Binary\Reader(Byte\slice($payload, offset: 1))->u32PrefixedBytes();
$server_kp = CryptoKeyExchange\generate_key_pair();
$server_ephemeral = $server_kp->publicKey->bytes;
$shared_secret = CryptoKeyExchange\agree($server_kp->secretKey, new CryptoKeyExchange\PublicKey($client_ephemeral));
$shared_secret_mpint = crypto_encode_mpint($shared_secret->bytes);
$host_key_blob = new Binary\Writer()
->u32PrefixedBytes('ssh-ed25519')
->u32PrefixedBytes($host_key['public'])
->toString();
$exchange_hash = kex_compute_exchange_hash(
$client_version,
$server_version,
$client_kex_payload,
$server_kex_payload,
$host_key_blob,
$client_ephemeral,
$server_ephemeral,
$shared_secret_mpint,
);
/** @var non-empty-string $signature_blob */
$signature_blob = new Binary\Writer()
->u32PrefixedBytes('ssh-ed25519')
->u32PrefixedBytes(Signing\sign($exchange_hash, new Signing\SecretKey($host_key['secret']))->bytes)
->toString();
transport_write_packet(
$t,
new Binary\Writer()
->u8(SSH_MSG_KEX_ECDH_REPLY)
->u32PrefixedBytes($host_key_blob)
->u32PrefixedBytes($server_ephemeral)
->u32PrefixedBytes($signature_blob)
->toString(),
);
return ['exchange_hash' => $exchange_hash, 'shared_secret_mpint' => $shared_secret_mpint];
}
function kex_send_newkeys(TransportState $t): void
{
transport_write_packet($t, new Binary\Writer()->u8(SSH_MSG_NEWKEYS)->toString());
}
function kex_receive_newkeys(TransportState $t): void
{
$payload = transport_read_packet($t);
if (Byte\ord($payload[0]) !== SSH_MSG_NEWKEYS) {
throw new RuntimeException('Expected NEWKEYS, got message type ' . Byte\ord($payload[0]));
}
}
function send_ext_info(TransportState $t): void
{
$sig_algs = Str\join(SSH_AUTH_ALGORITHMS, glue: ',');
transport_write_packet(
$t,
new Binary\Writer()
->u8(SSH_MSG_EXT_INFO)
->u32(1)
->u32PrefixedBytes('server-sig-algs')
->u32PrefixedBytes($sig_algs)
->toString(),
);
}
function auth_server(TransportState $t, string $session_id): string
{
$payload = transport_read_packet($t);
if (Byte\ord($payload[0]) !== SSH_MSG_SERVICE_REQUEST) {
throw new RuntimeException('Expected SERVICE_REQUEST, got message type ' . Byte\ord($payload[0]));
}
$service = new Binary\Reader(Byte\slice($payload, offset: 1))->u32PrefixedBytes();
if ($service !== 'ssh-userauth') {
throw new RuntimeException("Unexpected service request: {$service}");
}
transport_write_packet(
$t,
new Binary\Writer()
->u8(SSH_MSG_SERVICE_ACCEPT)
->u32PrefixedBytes('ssh-userauth')
->toString(),
);
while (true) {
$payload = transport_read_packet($t);
if (Byte\ord($payload[0]) !== SSH_MSG_USERAUTH_REQUEST) {
throw new RuntimeException('Expected USERAUTH_REQUEST, got message type ' . Byte\ord($payload[0]));
}
$reader = new Binary\Reader(Byte\slice($payload, offset: 1));
$username = $reader->u32PrefixedBytes();
$auth_service = $reader->u32PrefixedBytes();
$method = $reader->u32PrefixedBytes();
if ($method !== 'publickey') {
auth_send_failure($t);
continue;
}
$has_signature = $reader->u8() !== 0;
$algorithm = $reader->u32PrefixedBytes();
$public_key_blob = $reader->u32PrefixedBytes();
if (!Iter\contains(SSH_AUTH_ALGORITHMS, $algorithm)) {
auth_send_failure($t);
continue;
}
if (!$has_signature) {
auth_send_pk_ok($t, $algorithm, $public_key_blob);
continue;
}
$signature_blob = $reader->u32PrefixedBytes();
$signed_data = auth_build_signed_data($session_id, $username, $auth_service, $algorithm, $public_key_blob);
if (auth_verify_signature($algorithm, $public_key_blob, $signature_blob, $signed_data)) {
transport_write_packet($t, new Binary\Writer()->u8(SSH_MSG_USERAUTH_SUCCESS)->toString());
return $username;
}
auth_send_failure($t);
}
}
function auth_send_failure(TransportState $t): void
{
transport_write_packet(
$t,
new Binary\Writer()
->u8(SSH_MSG_USERAUTH_FAILURE)
->u32PrefixedBytes('publickey')
->u8(0)
->toString(),
);
}
function auth_send_pk_ok(TransportState $t, string $algorithm, string $public_key_blob): void
{
transport_write_packet(
$t,
new Binary\Writer()
->u8(SSH_MSG_USERAUTH_PK_OK)
->u32PrefixedBytes($algorithm)
->u32PrefixedBytes($public_key_blob)
->toString(),
);
}
function auth_build_signed_data(
string $session_id,
string $username,
string $service,
string $algorithm,
string $public_key_blob,
): string {
return new Binary\Writer()
->u32PrefixedBytes($session_id)
->u8(SSH_MSG_USERAUTH_REQUEST)
->u32PrefixedBytes($username)
->u32PrefixedBytes($service)
->u32PrefixedBytes('publickey')
->u8(1)
->u32PrefixedBytes($algorithm)
->u32PrefixedBytes($public_key_blob)
->toString();
}
function auth_verify_signature(
string $algorithm,
string $public_key_blob,
string $signature_blob,
string $signed_data,
): bool {
$sig_reader = new Binary\Reader($signature_blob);
$sig_reader->u32PrefixedBytes(); // algorithm name
/** @var non-empty-string $raw_signature */
$raw_signature = $sig_reader->u32PrefixedBytes();
return match ($algorithm) {
'ssh-ed25519' => auth_verify_ed25519($public_key_blob, $raw_signature, $signed_data),
'rsa-sha2-256' => auth_verify_rsa($public_key_blob, $raw_signature, $signed_data, OPENSSL_ALGO_SHA256),
'rsa-sha2-512' => auth_verify_rsa($public_key_blob, $raw_signature, $signed_data, OPENSSL_ALGO_SHA512),
'ssh-rsa' => auth_verify_rsa($public_key_blob, $raw_signature, $signed_data, OPENSSL_ALGO_SHA1),
'ecdsa-sha2-nistp256' => auth_verify_ecdsa($public_key_blob, $raw_signature, $signed_data, OPENSSL_ALGO_SHA256),
'ecdsa-sha2-nistp384' => auth_verify_ecdsa($public_key_blob, $raw_signature, $signed_data, OPENSSL_ALGO_SHA384),
'ecdsa-sha2-nistp521' => auth_verify_ecdsa($public_key_blob, $raw_signature, $signed_data, OPENSSL_ALGO_SHA512),
default => false,
};
}
/**
* @param non-empty-string $signature
*/
function auth_verify_ed25519(string $public_key_blob, string $signature, string $data): bool
{
$reader = new Binary\Reader($public_key_blob);
$reader->u32PrefixedBytes(); // "ssh-ed25519"
/** @var non-empty-string $public_key */
$public_key = $reader->u32PrefixedBytes();
return Signing\verify(new Signing\Signature($signature), $data, new Signing\PublicKey($public_key));
}
function auth_verify_rsa(string $public_key_blob, string $signature, string $data, int $algo): bool
{
$reader = new Binary\Reader($public_key_blob);
$reader->u32PrefixedBytes(); // "ssh-rsa"
$e = $reader->u32PrefixedBytes();
$n = $reader->u32PrefixedBytes();
$pem = der_rsa_public_key_to_pem($e, $n);
$key = openssl_pkey_get_public($pem);
if ($key === false) {
return false;
}
return openssl_verify($data, $signature, $key, $algo) === 1;
}
function auth_verify_ecdsa(string $public_key_blob, string $ssh_signature, string $data, int $algo): bool
{
$reader = new Binary\Reader($public_key_blob);
$reader->u32PrefixedBytes(); // "ecdsa-sha2-nistpXXX"
$curve = $reader->u32PrefixedBytes();
$point = $reader->u32PrefixedBytes();
$sig_reader = new Binary\Reader($ssh_signature);
$r = $sig_reader->u32PrefixedBytes();
$s = $sig_reader->u32PrefixedBytes();
$der_sig = der_sequence(der_integer($r) . der_integer($s));
$pem = der_ecdsa_public_key_to_pem($point, $curve);
$key = openssl_pkey_get_public($pem);
if ($key === false) {
return false;
}
return openssl_verify($data, $der_sig, $key, $algo) === 1;
}
function der_length(int $length): string
{
if ($length < 0x80) {
return chr($length);
}
if ($length < 0x100) {
return "\x81" . chr($length);
}
return "\x82" . pack('n', $length);
}
function der_sequence(string $contents): string
{
return "\x30" . der_length(Byte\length($contents)) . $contents;
}
function der_integer(string $bytes): string
{
$bytes = Byte\trim_left($bytes, char_mask: "\x00");
if ($bytes === '' || Byte\ord($bytes[0]) & 0x80) {
$bytes = "\x00" . $bytes;
}
return "\x02" . der_length(Byte\length($bytes)) . $bytes;
}
function der_bit_string(string $contents): string
{
$data = "\x00" . $contents;
return "\x03" . der_length(Byte\length($data)) . $data;
}
function der_rsa_public_key_to_pem(string $e, string $n): string
{
$rsa_key = der_sequence(der_integer($n) . der_integer($e));
$algorithm_id = der_sequence("\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01\x05\x00");
$spki = der_sequence($algorithm_id . der_bit_string($rsa_key));
return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), length: 64) . '-----END PUBLIC KEY-----';
}
function der_ecdsa_public_key_to_pem(string $point, string $curve): string
{
$ec_oid = "\x06\x07\x2a\x86\x48\xce\x3d\x02\x01";
$curve_oid = match ($curve) {
'nistp256' => "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07",
'nistp384' => "\x06\x05\x2b\x81\x04\x00\x22",
'nistp521' => "\x06\x05\x2b\x81\x04\x00\x23",
default => throw new RuntimeException("Unsupported curve: {$curve}"),
};
$spki = der_sequence(der_sequence($ec_oid . $curve_oid) . der_bit_string($point));
return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), length: 64) . '-----END PUBLIC KEY-----';
}
function channel_accept(TransportState $t): void
{
$payload = transport_read_packet($t);
if (Byte\ord($payload[0]) !== SSH_MSG_CHANNEL_OPEN) {
throw new RuntimeException('Expected CHANNEL_OPEN, got message type ' . Byte\ord($payload[0]));
}
$reader = new Binary\Reader(Byte\slice($payload, offset: 1));
$type = $reader->u32PrefixedBytes();
$remote_channel = $reader->u32();
if ($type !== 'session') {
throw new RuntimeException("Unsupported channel type: {$type}");
}
transport_write_packet(
$t,
new Binary\Writer()
->u8(SSH_MSG_CHANNEL_OPEN_CONFIRMATION)
->u32($remote_channel)
->u32(0)
->u32(SSH_INITIAL_WINDOW_SIZE)
->u32(SSH_MAX_PACKET_SIZE)
->toString(),
);
}
/**
* @param TransportState $t
*
* @return array{
* type: string,
* command: string,
* channel: int,
* pty: null|array{term: string, cols: int, rows: int}
* }
*/
function channel_receive_requests(TransportState $t): array
{
$pty = null;
while (true) {
$payload = transport_read_packet($t);
$msg_type = Byte\ord($payload[0]);
if ($msg_type === SSH_MSG_CHANNEL_WINDOW_ADJUST) {
continue;
}
if ($msg_type !== SSH_MSG_CHANNEL_REQUEST) {
throw new RuntimeException('Expected CHANNEL_REQUEST, got message type ' . $msg_type);
}
$reader = new Binary\Reader(Byte\slice($payload, offset: 1));
$channel = $reader->u32();
$request_type = $reader->u32PrefixedBytes();
$want_reply = $reader->u8() !== 0;
if ($request_type === 'pty-req') {
$term = $reader->u32PrefixedBytes();
$cols = $reader->u32();
$rows = $reader->u32();
$reader->u32();
$reader->u32();
$reader->u32PrefixedBytes();
$pty = ['term' => $term, 'cols' => $cols, 'rows' => $rows];
if ($want_reply) {
channel_send_success($t, $channel);
}
continue;
}
if ($request_type === 'env') {
$reader->u32PrefixedBytes();
$reader->u32PrefixedBytes();
if ($want_reply) {
channel_send_success($t, $channel);
}
continue;
}
if ($request_type === 'shell') {
if ($want_reply) {
channel_send_success($t, $channel);
}
return ['type' => 'shell', 'command' => '', 'channel' => $channel, 'pty' => $pty];
}
if ($request_type === 'exec') {
$command = $reader->u32PrefixedBytes();
if ($want_reply) {
channel_send_success($t, $channel);
}
return ['type' => 'exec', 'command' => $command, 'channel' => $channel, 'pty' => $pty];
}
if ($want_reply) {
channel_send_success($t, $channel);
}
}
}
function channel_send_success(TransportState $t, int $remote_channel): void
{
transport_write_packet($t, new Binary\Writer()->u8(SSH_MSG_CHANNEL_SUCCESS)->u32($remote_channel)->toString());
}
function channel_send_data(TransportState $t, int $remote_channel, string $data): void
{
$offset = 0;
$length = Byte\length($data);
while ($offset < $length) {
/** @var non-negative-int $chunk_size */
$chunk_size = min(SSH_MAX_CHANNEL_DATA, $length - $offset);
$chunk = Byte\slice($data, $offset, $chunk_size);
transport_write_packet(
$t,
new Binary\Writer()
->u8(SSH_MSG_CHANNEL_DATA)
->u32($remote_channel)
->u32PrefixedBytes($chunk)
->toString(),
);
$offset += $chunk_size;
}
}
/** @return null|array{channel: int, type: 'data', data: string}|array{channel: int, type: 'window-change', cols: int, rows: int} */
function channel_receive_data(TransportState $t): null|array
{
$payload = transport_read_packet($t);
$type = Byte\ord($payload[0]);
if ($type === SSH_MSG_CHANNEL_DATA) {
$reader = new Binary\Reader(Byte\slice($payload, offset: 1));
return ['channel' => $reader->u32(), 'type' => 'data', 'data' => $reader->u32PrefixedBytes()];
}
if ($type === SSH_MSG_CHANNEL_EOF || $type === SSH_MSG_CHANNEL_CLOSE) {
return null;
}
if ($type === SSH_MSG_CHANNEL_REQUEST) {
$reader = new Binary\Reader(Byte\slice($payload, offset: 1));
$channel = $reader->u32();
$request_type = $reader->u32PrefixedBytes();
$reader->u8();
if ($request_type === 'window-change') {
return [
'channel' => $channel,
'type' => 'window-change',
'cols' => $reader->u32(),
'rows' => $reader->u32(),
];
}
return channel_receive_data($t);
}
if ($type === SSH_MSG_CHANNEL_WINDOW_ADJUST) {
return channel_receive_data($t);
}
throw new RuntimeException("Unexpected message type during channel data: {$type}");
}
function channel_send_eof(TransportState $t, int $remote_channel): void
{
transport_write_packet($t, new Binary\Writer()->u8(SSH_MSG_CHANNEL_EOF)->u32($remote_channel)->toString());
}
function channel_send_close(TransportState $t, int $remote_channel): void
{
transport_write_packet($t, new Binary\Writer()->u8(SSH_MSG_CHANNEL_CLOSE)->u32($remote_channel)->toString());
}
final class ChannelWriteHandle implements IO\WriteHandleInterface
{
private bool $closed = false;
public function __construct(
private readonly TransportState $transport,
private readonly int $remote_channel,
) {}
public function tryWrite(string $bytes): int
{
if ($this->closed) {
throw new IO\Exception\AlreadyClosedException('Channel write handle is closed.');
}
channel_send_data($this->transport, $this->remote_channel, $bytes);
return Byte\length($bytes);
}
public function write(string $bytes, null|DateTime\Duration $timeout = null): int
{
return $this->tryWrite($bytes);
}
public function writeAll(string $bytes, null|DateTime\Duration $timeout = null): void
{
$this->tryWrite($bytes);
}
public function close(): void
{
$this->closed = true;
}
}
function chat_join(ChatRoom $room, string $name, Channel\SenderInterface $sender): int
{
$id = $room->next_id++;
$room->users[$id] = new ChatUser($id, $name, $sender);
chat_system_message($room, "{$name} joined the chat");
return $id;
}
function chat_leave(ChatRoom $room, int $id): void
{
$user = $room->users[$id] ?? null;
if ($user === null) {
return;
}
unset($room->users[$id]);
$user->sender->close();
chat_system_message($room, "{$user->name} left the chat");
}
function chat_broadcast(ChatRoom $room, int $from_id, string $text): void
{
$user = $room->users[$from_id] ?? null;
if ($user === null) {
return;
}
$message = new ChatMessage($user->name, $text);
foreach ($room->users as $u) {
try {
$u->sender->trySend($message);
} catch (Channel\Exception\ClosedChannelException|Channel\Exception\FullChannelException) {
continue;
}
}
}
function chat_system_message(ChatRoom $room, string $text): void
{
$message = new ChatMessage('system', $text, system: true);
foreach ($room->users as $u) {
try {
$u->sender->trySend($message);
} catch (Channel\Exception\ClosedChannelException|Channel\Exception\FullChannelException) {
continue;
}
}
}
/** @return list<string> */
function chat_user_names(ChatRoom $room): array
{
$names = [];
foreach ($room->users as $u) {
$names[] = $u->name;
}
return $names;
}
function handle_chat_input(Event\Key $event, ChatState $state, ChatRoom $room, null|int $user_id): void
{
if ($event->is('enter') && $user_id !== null) {
$text = Str\trim($state->input_value);
if ($text !== '') {
chat_broadcast($room, $user_id, $text);
$state->input_value = '';
$state->input_cursor = 0;
}
return;
}
handle_text_input($event, $state);
}
function handle_text_input(Event\Key $event, ChatState $state): void
{
if ($event->char !== null) {
$before = Str\slice($state->input_value, offset: 0, length: $state->input_cursor);
$after = Str\slice($state->input_value, $state->input_cursor);
$state->input_value = $before . $event->char . $after;
$state->input_cursor += Str\length($event->char);
return;
}
if ($event->is('backspace')) {
if ($state->input_cursor > 0) {
$before = Str\slice($state->input_value, offset: 0, length: $state->input_cursor - 1);
$after = Str\slice($state->input_value, $state->input_cursor);
$state->input_value = $before . $after;
$state->input_cursor--;
}
return;
}
if ($event->is('delete')) {
if ($state->input_cursor < Str\length($state->input_value)) {
$before = Str\slice($state->input_value, offset: 0, length: $state->input_cursor);
$after = Str\slice($state->input_value, offset: $state->input_cursor + 1);
$state->input_value = $before . $after;
}
return;
}
if ($event->is('left')) {
if ($state->input_cursor > 0) {
$state->input_cursor--;
}
return;
}
if ($event->is('right')) {
if ($state->input_cursor < Str\length($state->input_value)) {
$state->input_cursor++;
}
return;
}
if ($event->is('home')) {
$state->input_cursor = 0;
return;
}
if ($event->is('end')) {
$state->input_cursor = Str\length($state->input_value);
return;
}
}
function render_chat_screen(Terminal\Frame $frame, ChatState $state): void
{
$area = $frame->rect();
$buffer = $frame->buffer();
$regions = Layout\vertical($area, [
Layout\fill(),
Layout\fixed(3),
Layout\fixed(1),
]);
$lines = [];
foreach ($state->messages as $msg) {
if ($msg->system) {
$lines[] = Widget\Line::new([
Widget\Span::styled(' [system] ', Ansi\foreground(Color\yellow()), Style\bold()),
Widget\Span::styled($msg->text, Ansi\foreground(Color\yellow()), Style\dim()),
]);
continue;
}
$lines[] = Widget\Line::new([
Widget\Span::styled(' [' . $msg->sender . '] ', Ansi\foreground(Color\cyan()), Style\bold()),
Widget\Span::raw($msg->text),
]);
}
if ($lines === []) {
$lines[] = Widget\Line::new([Widget\Span::styled(' No messages yet. Say hello!', Style\dim())]);
}
Widget\Block::new()
->title(' Chat Room ')
->border(Widget\Border::rounded(Ansi\foreground(Color\cyan())))
->render($regions[0], Widget\Paragraph::new($lines)->wrap(Widget\Wrap::Word)->scroll(PHP_INT_MAX), $buffer);
Widget\Block::new()->border(Widget\Border::rounded(Ansi\foreground(Color\green())))->render(
$regions[1],
Widget\TextInput::new()
->value($state->input_value)
->cursor($state->input_cursor)
->placeholder('Type a message...'),
$buffer,
);
$state->frame_count++;
$now = DateTime\Timestamp::monotonic();
$elapsed = $now->getSeconds() - $state->last_fps_time->getSeconds();
if ($elapsed >= 1) {
$state->fps = $state->frame_count;
$state->frame_count = 0;
$state->last_fps_time = $now;
}
$users = $state->online_users !== [] ? Str\join($state->online_users, glue: ', ') : 'none';
$status = " Users: {$users} | Ctrl+C to quit | ";
$buffer->setString($regions[2]->x, $regions[2]->y, $status, [Ansi\foreground(Color\bright_white()), Style\dim()]);
$buffer->setString($regions[2]->x + Str\length($status), $regions[2]->y, "{$state->fps} FPS", [
Ansi\foreground(Color\green()),
Style\bold(),
]);
}
/**
* @param array{public: non-empty-string, secret: non-empty-string} $host_key
*/
function handle_client(TCP\StreamInterface $stream, array $host_key, ChatRoom $room): void
{
$t = new TransportState($stream);
$t->stream->writeAll(SSH_VERSION . "\r\n");
$client_version = transport_read_line($t);
$server_kex = kex_send_init($t);
$client_kex = kex_receive_init($t);
$result = kex_server_exchange($t, $host_key, $client_version, SSH_VERSION, $client_kex, $server_kex);
$session_id = $result['exchange_hash'];
$keys = crypto_derive_keys($result['shared_secret_mpint'], $result['exchange_hash'], $session_id);
kex_send_newkeys($t);
kex_receive_newkeys($t);
$t->encryptor = new CryptoState(
$keys['server_to_client_mac'],
$keys['server_to_client_key'],
$keys['server_to_client_iv'],
);
$t->encryptor->sequence_number = $t->send_sequence;
$t->decryptor = new CryptoState(
$keys['client_to_server_mac'],
$keys['client_to_server_key'],
$keys['client_to_server_iv'],
);
$t->decryptor->sequence_number = $t->recv_sequence;
send_ext_info($t);
$username = auth_server($t, $session_id);
channel_accept($t);
$request = channel_receive_requests($t);
if ($request['type'] !== 'shell') {
channel_send_data(
$t,
$request['channel'],
'This is a chat server. Connect with: ssh -p ' . SSH_PORT . ' user@' . SSH_HOST . "\n",
);
channel_send_eof($t, $request['channel']);
channel_send_close($t, $request['channel']);
return;
}
$cols = $request['pty']['cols'] ?? 80;
$rows = $request['pty']['rows'] ?? 24;
$remote_channel = $request['channel'];
[$pipe_read, $pipe_write] = IO\pipe();
$channel_writer = new ChannelWriteHandle($t, $remote_channel);
/** @var Channel\ReceiverInterface<ChatMessage> $receiver */
/** @var Channel\SenderInterface<ChatMessage> $sender */
[$receiver, $sender] = Channel\bounded(100);
$state = new ChatState();
$state->user_name = $username;
$state->last_fps_time = DateTime\Timestamp::monotonic();
$user_id = chat_join($room, $username, $sender);
$state->online_users = chat_user_names($room);
$app = Terminal\Application::custom(
state: $state,
input: $pipe_read,
output: $channel_writer,
width: $cols,
height: $rows,
title: 'PSL Chat',
tickInterval: DateTime\Duration::milliseconds(8),
);
$room->apps[$user_id] = $app;
$app->on(Event\Key::class, static function (Event\Key $event, ChatState $state) use ($app, $room, &$user_id): void {
if ($event->is('ctrl+c')) {
$app->stop();
return;
}
handle_chat_input($event, $state, $room, $user_id);
});
$ssh_reader = Async\run(static function () use ($t, $pipe_write, $app): void {
try {
while (true) {
$packet = channel_receive_data($t);
if ($packet === null) {
$app->stop();
break;
}
if ($packet['type'] === 'window-change') {
$app->dispatch(new Event\Resize($packet['cols'] ?? 0, $packet['rows'] ?? 0));
continue;
}
if ($packet['type'] === 'data') {
$pipe_write->writeAll($packet['data']);
}
}
} catch (Throwable) {
$app->stop();
} finally {
$pipe_write->close();
}
});
$msg_receiver = Async\run(static function () use ($receiver, $state, $room): void {
while (true) {
try {
$state->messages[] = $receiver->receive();
} catch (Channel\Exception\ClosedChannelException) {
break;
}
$state->online_users = chat_user_names($room);
}
});
$app->run(static function (Terminal\Frame $frame, ChatState $state): void {
render_chat_screen($frame, $state);
});
unset($room->apps[$user_id]);
chat_leave($room, $user_id);
$receiver->close();
$pipe_write->close();
$channel_writer->close();
try {
channel_send_eof($t, $remote_channel);
channel_send_close($t, $remote_channel);
} catch (Throwable) {
// @mago-expect lint:no-empty-catch-clause
}
$ssh_reader->ignore();
$msg_receiver->ignore();
}
Async\main(static function (): int {
$host_key = host_key_generate();
$room = new ChatRoom();
$listener = TCP\listen(SSH_HOST, SSH_PORT);
/** @var bool $shutting_down */
$shutting_down = false;
$shutdown = static function () use ($room, $listener, &$shutting_down): void {
if ($shutting_down) {
return;
}
$shutting_down = true;
IO\write_line('Shutting down...');
$listener->close();
foreach ($room->apps as $app) {
$app->stop();
}
};
Async\Scheduler::unreference(Async\Scheduler::onSignal(SIGINT, $shutdown));
Async\Scheduler::unreference(Async\Scheduler::onSignal(SIGTERM, $shutdown));
IO\write_line('SSH Chat server listening on %s:%d', SSH_HOST, SSH_PORT);
IO\write_line('Connect with: ssh -p %d %s', SSH_PORT, SSH_HOST);
while (true) {
try {
$connection = $listener->accept();
} catch (Network\Exception\AlreadyStoppedException) {
break;
}
Async\run(static function () use ($connection, $host_key, $room): void {
try {
handle_client($connection, $host_key, $room);
} catch (Throwable $e) {
IO\write_error_line('Client error: %s', $e->getMessage());
} finally {
$connection->close();
}
})->ignore();
}
return 0;
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment