Files
EdgeSSH/src/backend/session.ts
T
cmliu 623d270eec feat: implement SSH protocol components including key exchange, packet handling, and transport
- Add KEXInitBuilder and parsing functions for SSH key exchange initialization.
- Implement key derivation for session keys using shared secrets and exchange hashes.
- Create SSHPacketParser and SSHPacketBuilder for handling SSH packets.
- Establish SSHTransport for managing version exchange and connection setup.
- Introduce utility functions for data manipulation and encoding.
- Define types for SSH packets, key exchange messages, and session configurations.
- Set up worker environment with session management and request handling.
- Configure TypeScript and Vite for frontend and worker builds.
- Add tests and coverage configuration for SSH components.
- Initialize Wrangler configuration for deployment.
2026-07-27 19:05:23 +08:00

763 lines
36 KiB
TypeScript

import {
SSH_MSG_CHANNEL_CLOSE,
SSH_MSG_CHANNEL_DATA,
SSH_MSG_CHANNEL_EOF,
SSH_MSG_CHANNEL_EXTENDED_DATA,
SSH_MSG_CHANNEL_FAILURE,
SSH_MSG_CHANNEL_OPEN_CONFIRMATION,
SSH_MSG_CHANNEL_OPEN_FAILURE,
SSH_MSG_CHANNEL_SUCCESS,
SSH_MSG_CHANNEL_WINDOW_ADJUST,
SSH_MSG_DEBUG,
SSH_MSG_DISCONNECT,
SSH_MSG_EXT_INFO,
SSH_MSG_GLOBAL_REQUEST,
SSH_MSG_IGNORE,
SSH_MSG_KEXINIT,
SSH_MSG_KEX_ECDH_REPLY,
SSH_MSG_NEWKEYS,
SSH_MSG_REQUEST_FAILURE,
SSH_MSG_REQUEST_SUCCESS,
SSH_MSG_SERVICE_ACCEPT,
SSH_MSG_SERVICE_REQUEST,
SSH_MSG_UNIMPLEMENTED,
SSH_MSG_USERAUTH_FAILURE,
SSH_MSG_USERAUTH_SUCCESS,
type SSHConnectionConfig,
type SSHPacket,
type SessionKeys,
} from '../types';
import { isSSH2Identification, SSHTransport } from '../ssh/transport';
import { SSHPacketBuilder, SSHPacketParser, nextSequenceNumber } from '../ssh/packet';
import { KEXInitBuilder, filterExtInfo, negotiate, parseKEXInit, parseServerSigAlgs } from '../ssh/kex';
import {
KEX_ALGORITHM_ECDH_NISTP256,
getCipherSpec,
getMacSpec,
isCurve25519KEXAlgorithm,
} from '../ssh/algorithms';
import { ECDHKeyExchange } from '../ssh/kex-ecdh';
import { Curve25519KeyExchange, type Curve25519KeyPair } from '../ssh/kex-curve25519';
import { KeyDerivation } from '../ssh/keys';
import { SSHAESCTRCipher, SSHAESGCMCipher, SSHHMAC } from '../ssh/crypto';
import { SSHAuth } from '../ssh/auth';
import { SSHChannel, type ChannelDataChunk } from '../ssh/channel';
import { encodeString, readUint32, toBufferSource } from '../ssh/utils';
type Cipher = SSHAESGCMCipher | SSHAESCTRCipher;
type Phase = 'version' | 'kex' | 'host-confirm' | 'auth' | 'pty' | 'shell' | 'ready' | 'closed';
const LOCAL_WINDOW_THRESHOLD = 512 * 1024;
const MAX_VERSION_BYTES = 8192;
const MAX_QUEUED_INPUT = 1024 * 1024;
const KEEPALIVE_NAME = new TextEncoder().encode('keepalive@openssh.com');
export class SSHSession {
private readonly encoder = new TextEncoder();
private readonly decoder = new TextDecoder();
private readonly transport = new SSHTransport();
private readonly parser = new SSHPacketParser();
private readonly channel = new SSHChannel();
private readonly config: SSHConnectionConfig;
private readonly ws: WebSocket;
private readonly socket: Socket;
private phase: Phase = 'version';
private versionBuffer = new Uint8Array();
private writer: WritableStreamDefaultWriter<Uint8Array> | null = null;
private sendSequence = 0;
private sendChain: Promise<void> = Promise.resolve();
private localKex: Uint8Array | null = null;
private remoteKex: Uint8Array | null = null;
private kexName: string | null = null;
private hostKeyAlgorithm: string | null = null;
private ecdhPair: CryptoKeyPair | null = null;
private curvePair: Curve25519KeyPair | null = null;
private clientPublic: Uint8Array | null = null;
private sessionId: Uint8Array | null = null;
private keys: SessionKeys | null = null;
private cipherC2S = 'aes128-gcm@openssh.com';
private cipherS2C = 'aes128-gcm@openssh.com';
private macC2S = 'none';
private macS2C = 'none';
private encryptor: Cipher | null = null;
private decryptor: Cipher | null = null;
private signer: SSHHMAC | null = null;
private verifier: SSHHMAC | null = null;
private serverSigAlgs: string[] = [];
private authRequestSent = false;
private pendingChannelRequest: 'pty' | 'shell' | null = null;
private ignoreNextKexPacket = false;
private inputQueue: Uint8Array[] = [];
private queueHeadOffset = 0;
private queuedBytes = 0;
private flushingInput = false;
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
private keepalivePending = 0;
private shellTimer: ReturnType<typeof setTimeout> | null = null;
private readTask: Promise<void> | null = null;
private pendingHostConfirmation: { fingerprint: string; resolve: (accepted: boolean) => void } | null = null;
constructor(ws: WebSocket, socket: Socket, config: SSHConnectionConfig) {
this.ws = ws;
this.socket = socket;
this.config = config;
}
async start(): Promise<void> {
this.status('version_exchange', 'Exchanging SSH protocol versions');
await this.write(this.transport.getLocalIdentification());
this.readTask = this.readLoop();
}
async handleClientMessage(message: string | ArrayBuffer): Promise<void> {
if (this.phase === 'closed') return;
if (message instanceof ArrayBuffer) {
if (message.byteLength > 64 * 1024) throw new Error('Binary terminal input exceeds 64 KiB');
this.queueInput(new Uint8Array(message));
return;
}
let frame: unknown;
try { frame = JSON.parse(message); } catch { throw new Error('Invalid WebSocket control JSON'); }
if (!frame || typeof frame !== 'object' || Array.isArray(frame)) throw new Error('Invalid WebSocket control message');
const value = frame as Record<string, unknown>;
if (value.type === 'host_key_decision') {
const pending = this.pendingHostConfirmation;
if (!pending || typeof value.accept !== 'boolean' || value.fingerprint !== pending.fingerprint) throw new Error('Invalid host key decision');
this.pendingHostConfirmation = null;
pending.resolve(value.accept);
return;
}
if (value.type === 'ping') {
this.sendJson({ type: 'pong' });
return;
}
const resize = value.type === 'resize'
? [value.cols, value.rows]
: Array.isArray(value.resize) ? value.resize : null;
if (resize) {
await this.resize(resize[0], resize[1]);
return;
}
if (value.type === 'input' || (value.type === undefined && typeof value.data === 'string')) {
if (typeof value.data !== 'string' || value.data.length > 256 * 1024) throw new Error('Invalid terminal input');
this.queueInput(this.encoder.encode(value.data));
return;
}
throw new Error('Unsupported WebSocket message');
}
close(normal = false): void {
if (this.phase === 'closed') return;
this.phase = 'closed';
if (this.keepaliveTimer) clearInterval(this.keepaliveTimer);
if (this.shellTimer) clearTimeout(this.shellTimer);
this.keepaliveTimer = null;
this.shellTimer = null;
this.inputQueue = [];
this.queuedBytes = 0;
this.pendingHostConfirmation?.resolve(false);
this.pendingHostConfirmation = null;
try { this.writer?.releaseLock(); } catch { /* already released */ }
this.writer = null;
try { this.socket.close(); } catch { /* already closed */ }
try { this.ws.close(normal ? 1000 : 1011, normal ? 'Session closed' : 'SSH session failed'); } catch { /* already closed */ }
}
private async readLoop(): Promise<void> {
const reader = this.socket.readable.getReader();
try {
while (this.phase !== 'closed') {
const { value, done } = await reader.read();
if (done) {
this.status('remote_closed', 'The SSH server closed the connection');
this.close(true);
return;
}
if (value.length > 0) await this.consume(value);
}
} catch (error) {
this.fail(error, 'read_error');
} finally {
try { reader.releaseLock(); } catch { /* ignored */ }
}
}
private async consume(data: Uint8Array): Promise<void> {
if (this.phase === 'version') {
const merged = new Uint8Array(this.versionBuffer.length + data.length);
merged.set(this.versionBuffer);
merged.set(data, this.versionBuffer.length);
if (merged.length > MAX_VERSION_BYTES) throw new Error('SSH identification banner is too large');
this.versionBuffer = merged;
let start = 0;
while (true) {
const newline = this.versionBuffer.indexOf(0x0a, start);
if (newline < 0) return;
const bytes = this.versionBuffer.subarray(start, newline + 1);
start = newline + 1;
const line = this.decoder.decode(bytes).replace(/\r?\n$/, '');
if (!line.startsWith('SSH-')) continue;
if (!isSSH2Identification(line)) throw new Error('The server does not support SSH 2.0');
this.transport.handleVersionExchange(`${line}\r\n`);
const remaining = this.versionBuffer.subarray(start);
this.versionBuffer = new Uint8Array();
this.phase = 'kex';
this.status('version_ready', 'Version exchange complete; negotiating keys');
await this.startKex();
if (remaining.length > 0) {
this.parser.feed(remaining);
await this.processPackets();
}
return;
}
}
this.parser.feed(data);
await this.processPackets();
}
private async startKex(): Promise<void> {
this.localKex = KEXInitBuilder.build();
await this.sendPlain(this.localKex);
}
private async processPackets(): Promise<void> {
while (this.phase !== 'closed') {
// NEWKEYS may enable inbound encryption while this same TCP chunk still
// contains encrypted packets, so recompute framing on every iteration.
const spec = this.decryptor ? getCipherSpec(this.cipherS2C) : null;
const macLength = this.decryptor && !spec?.aead ? getMacSpec(this.macS2C).length : 0;
const packet = await this.parser.nextPacket(
spec?.blockSize ?? 8,
this.decryptor ? (data, sequence, aad, commit) => this.decryptor!.decrypt(data, sequence, aad, commit) : (data) => data,
Boolean(spec?.aead),
macLength,
this.verifier ? (data, mac, sequence) => this.verifier!.verify(data, sequence, mac) : undefined,
);
if (!packet) return;
await this.handlePacket(packet);
}
}
private async handlePacket(packet: SSHPacket): Promise<void> {
const type = packet.payload[0];
if (type === SSH_MSG_DISCONNECT) {
this.validateDisconnect(packet.payload);
this.status('remote_closed', 'The SSH server closed the connection');
this.close(true);
return;
}
if (type === SSH_MSG_IGNORE) {
const data = this.readBytes(packet.payload, 1);
if (data.next !== packet.payload.length) throw new Error('Malformed SSH ignore message');
return;
}
if (type === SSH_MSG_DEBUG) {
this.validateDebug(packet.payload);
return;
}
if (type === SSH_MSG_UNIMPLEMENTED) {
if (packet.payload.length !== 5) throw new Error('Malformed SSH unimplemented message');
return;
}
if (type === SSH_MSG_GLOBAL_REQUEST) {
await this.handleGlobalRequest(packet.payload);
return;
}
if (type === SSH_MSG_REQUEST_SUCCESS || type === SSH_MSG_REQUEST_FAILURE) {
this.keepalivePending = 0;
return;
}
if (type === SSH_MSG_KEXINIT && this.phase !== 'kex' && this.phase !== 'host-confirm') throw new Error('Server-initiated SSH rekey is not supported');
if (this.phase === 'kex' || this.phase === 'host-confirm') await this.handleKex(type, packet.payload);
else if (this.phase === 'auth') await this.handleAuth(type, packet.payload);
else if (this.phase === 'pty' || this.phase === 'shell' || this.phase === 'ready') await this.handleChannel(type, packet.payload);
}
private async handleKex(type: number, payload: Uint8Array): Promise<void> {
if (this.ignoreNextKexPacket) {
this.ignoreNextKexPacket = false;
return;
}
if (type === SSH_MSG_KEXINIT) {
if (this.encryptor) throw new Error('SSH rekey is not supported by this terminal session');
this.remoteKex = payload;
const client = parseKEXInit(this.localKex!);
const server = parseKEXInit(payload);
this.kexName = negotiate(filterExtInfo(client.kexAlgorithms), filterExtInfo(server.kexAlgorithms), 'key exchange algorithm');
this.hostKeyAlgorithm = negotiate(client.hostKeyAlgorithms, server.hostKeyAlgorithms, 'host key algorithm');
this.ignoreNextKexPacket = server.firstKexPacketFollows
&& (filterExtInfo(server.kexAlgorithms)[0] !== this.kexName || server.hostKeyAlgorithms[0] !== this.hostKeyAlgorithm);
this.cipherC2S = negotiate(client.encryptionC2S, server.encryptionC2S, 'client cipher');
this.cipherS2C = negotiate(client.encryptionS2C, server.encryptionS2C, 'server cipher');
// RFC 4253 negotiates MAC lists even when the chosen AEAD cipher does
// not use the result on the wire.
const negotiatedMacC2S = negotiate(client.macC2S, server.macC2S, 'client MAC');
const negotiatedMacS2C = negotiate(client.macS2C, server.macS2C, 'server MAC');
this.macC2S = getCipherSpec(this.cipherC2S).aead ? 'none' : negotiatedMacC2S;
this.macS2C = getCipherSpec(this.cipherS2C).aead ? 'none' : negotiatedMacS2C;
if (negotiate(client.compressionC2S, server.compressionC2S, 'client compression') !== 'none'
|| negotiate(client.compressionS2C, server.compressionS2C, 'server compression') !== 'none') {
throw new Error('SSH compression is not supported');
}
await this.sendEcdhInit();
return;
}
if (type === SSH_MSG_KEX_ECDH_REPLY) {
await this.handleEcdhReply(payload);
return;
}
if (type === SSH_MSG_NEWKEYS) {
if (!this.keys || !this.encryptor) throw new Error('Received NEWKEYS before completing key exchange');
// The server's new keys apply only to subsequent inbound packets.
await this.enableInboundEncryption();
this.phase = 'auth';
this.status('authenticating', 'Encrypted transport established; authenticating');
await this.sendEncrypted(new Uint8Array([SSH_MSG_SERVICE_REQUEST, ...encodeString('ssh-userauth')]));
}
}
private async sendEcdhInit(): Promise<void> {
if (this.kexName && isCurve25519KEXAlgorithm(this.kexName)) {
this.curvePair = await Curve25519KeyExchange.generateKeyPair();
this.clientPublic = await Curve25519KeyExchange.exportRawPublicKey(this.curvePair);
await this.sendPlain(Curve25519KeyExchange.buildInit(this.clientPublic));
return;
}
if (this.kexName === KEX_ALGORITHM_ECDH_NISTP256) {
this.ecdhPair = await ECDHKeyExchange.generateKeyPair();
this.clientPublic = await ECDHKeyExchange.exportRawPublicKey(this.ecdhPair);
await this.sendPlain(ECDHKeyExchange.buildInit(this.clientPublic));
return;
}
throw new Error(`Unsupported key exchange algorithm: ${this.kexName ?? 'none'}`);
}
private async handleEcdhReply(payload: Uint8Array): Promise<void> {
if (!this.kexName || !this.clientPublic || !this.localKex || !this.remoteKex) throw new Error('Unexpected key exchange reply');
const { hostKey, serverRawPublicKey, signature } = ECDHKeyExchange.parseReply(payload);
let sharedSecret: Uint8Array;
let hash: Uint8Array;
if (isCurve25519KEXAlgorithm(this.kexName)) {
if (!this.curvePair) throw new Error('Missing Curve25519 key');
sharedSecret = await Curve25519KeyExchange.computeSharedSecret(this.curvePair.privateKey, serverRawPublicKey);
hash = await Curve25519KeyExchange.computeExchangeHash(this.transport.getLocalVersion(), this.transport.getRemoteVersion(), this.localKex, this.remoteKex, hostKey, this.clientPublic, serverRawPublicKey, sharedSecret);
} else {
if (!this.ecdhPair) throw new Error('Missing ECDH key');
sharedSecret = await ECDHKeyExchange.computeSharedSecret(this.ecdhPair.privateKey, serverRawPublicKey);
hash = await ECDHKeyExchange.computeExchangeHash(this.transport.getLocalVersion(), this.transport.getRemoteVersion(), this.localKex, this.remoteKex, hostKey, this.clientPublic, serverRawPublicKey, sharedSecret);
}
const keyTypeLength = readUint32(hostKey, 0);
if (keyTypeLength > 128 || keyTypeLength + 4 > hostKey.length) throw new Error('Malformed SSH host key');
const keyType = this.decoder.decode(hostKey.subarray(4, 4 + keyTypeLength));
if (!this.isHostKeyAlgorithmCompatible(this.hostKeyAlgorithm, keyType)) throw new Error(`Server used ${keyType}, but negotiated ${this.hostKeyAlgorithm ?? 'no host key algorithm'}`);
const fingerprint = `SHA256:${this.base64(new Uint8Array(await crypto.subtle.digest('SHA-256', toBufferSource(hostKey))))}`;
if (this.config.expectedFingerprint && this.config.expectedFingerprint !== fingerprint) throw new Error(`Host key mismatch: expected ${this.config.expectedFingerprint}, received ${fingerprint}`);
if (!await this.verifyHostSignature(hostKey, signature, hash)) throw new Error('SSH host key signature verification failed');
if (this.config.expectedFingerprint) {
this.sendJson({ type: 'host_key', fingerprint, keyType, trusted: true });
} else if (!await this.confirmHostKey(fingerprint, keyType)) {
throw new Error('Host key was not accepted');
}
this.status('host_key_verified', `Host key verified (${keyType})`);
this.sessionId ??= hash;
const c2s = getCipherSpec(this.cipherC2S);
const s2c = getCipherSpec(this.cipherS2C);
this.keys = await KeyDerivation.deriveKeys(
sharedSecret,
hash,
this.sessionId,
c2s.ivLength,
s2c.ivLength,
getMacSpec(this.macC2S).keyLength,
getMacSpec(this.macS2C).keyLength,
c2s.keyLength,
s2c.keyLength,
);
// Each direction switches independently at its NEWKEYS boundary. Sending
// ours now also works with servers that wait before sending their NEWKEYS.
await this.sendPlain(new Uint8Array([SSH_MSG_NEWKEYS]));
await this.enableOutboundEncryption();
}
private async confirmHostKey(fingerprint: string, keyType: string): Promise<boolean> {
this.phase = 'host-confirm';
this.status('host_key_confirmation', 'Confirm this host key before credentials are sent');
return new Promise<boolean>((resolve) => {
let settled = false;
const finish = (accepted: boolean): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (this.pendingHostConfirmation?.fingerprint === fingerprint) this.pendingHostConfirmation = null;
if (accepted && this.phase !== 'closed') this.phase = 'kex';
resolve(accepted);
};
const timeout = setTimeout(() => finish(false), 30_000);
this.pendingHostConfirmation = { fingerprint, resolve: finish };
// Register the decision handler before notifying the browser.
this.sendJson({ type: 'host_key', fingerprint, keyType, trusted: false });
});
}
private async verifyHostSignature(hostKey: Uint8Array, signature: Uint8Array, hash: Uint8Array): Promise<boolean> {
let offset = 0;
const keyType = this.readString(hostKey, offset); offset = keyType.next;
let sigOffset = 0;
const signatureType = this.readString(signature, sigOffset); sigOffset = signatureType.next;
const signatureField = this.readBytes(signature, sigOffset);
if (signatureField.next !== signature.length) throw new Error('Malformed SSH host signature');
const rawSignature = signatureField.value;
if (keyType.value === 'ssh-ed25519') {
if (this.hostKeyAlgorithm !== 'ssh-ed25519' || signatureType.value !== 'ssh-ed25519') throw new Error('Ed25519 host key or signature does not match the negotiated algorithm');
const keyField = this.readBytes(hostKey, offset);
if (keyField.next !== hostKey.length || keyField.value.length !== 32 || rawSignature.length !== 64) throw new Error('Malformed Ed25519 host key or signature');
const rawKey = keyField.value;
const key = await crypto.subtle.importKey('raw', toBufferSource(rawKey), { name: 'Ed25519' }, false, ['verify']);
return crypto.subtle.verify('Ed25519', key, toBufferSource(rawSignature), toBufferSource(hash));
}
if (keyType.value.startsWith('ecdsa-sha2-nistp')) {
if (this.hostKeyAlgorithm !== keyType.value || signatureType.value !== keyType.value) throw new Error('ECDSA host key or signature does not match the negotiated algorithm');
const curveName = this.readString(hostKey, offset); offset = curveName.next;
const keyField = this.readBytes(hostKey, offset);
if (keyField.next !== hostKey.length) throw new Error('Malformed ECDSA host key');
const rawKey = keyField.value;
const curve = keyType.value.endsWith('256') ? 'P-256' : keyType.value.endsWith('384') ? 'P-384' : 'P-521';
if (curveName.value !== keyType.value.replace('ecdsa-sha2-', '')) throw new Error('ECDSA curve name does not match the host key type');
const digest = curve === 'P-256' ? 'SHA-256' : curve === 'P-384' ? 'SHA-384' : 'SHA-512';
const coordinateBytes = curve === 'P-256' ? 32 : curve === 'P-384' ? 48 : 66;
const key = await crypto.subtle.importKey('raw', toBufferSource(rawKey), { name: 'ECDSA', namedCurve: curve }, false, ['verify']);
return crypto.subtle.verify({ name: 'ECDSA', hash: digest }, key, toBufferSource(this.sshEcdsaToRaw(rawSignature, coordinateBytes)), toBufferSource(hash));
}
if (keyType.value === 'ssh-rsa') {
if (signatureType.value !== 'rsa-sha2-256' && signatureType.value !== 'rsa-sha2-512') throw new Error(`Unsupported RSA host signature type: ${signatureType.value}`);
if (signatureType.value !== this.hostKeyAlgorithm) throw new Error(`RSA signature ${signatureType.value} does not match negotiated ${this.hostKeyAlgorithm}`);
const exponent = this.readBytes(hostKey, offset); offset = exponent.next;
const modulusField = this.readBytes(hostKey, offset);
if (modulusField.next !== hostKey.length || exponent.value.length === 0 || modulusField.value.length === 0) throw new Error('Malformed RSA host key');
const modulus = modulusField.value;
const digest = signatureType.value === 'rsa-sha2-512' ? 'SHA-512' : 'SHA-256';
const key = await crypto.subtle.importKey('jwk', { kty: 'RSA', e: this.base64UrlUnsigned(exponent.value), n: this.base64UrlUnsigned(modulus), ext: true }, { name: 'RSASSA-PKCS1-v1_5', hash: digest }, false, ['verify']);
return crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, toBufferSource(rawSignature), toBufferSource(hash));
}
throw new Error(`Unsupported host key type: ${keyType.value}`);
}
private async enableOutboundEncryption(): Promise<void> {
if (!this.keys) throw new Error('Session keys are not available');
const c2s = getCipherSpec(this.cipherC2S);
this.encryptor = c2s.mode === 'gcm'
? new SSHAESGCMCipher(this.keys.encKeyClientToServer.subarray(0, c2s.keyLength), this.keys.ivClientToServer)
: new SSHAESCTRCipher(this.keys.encKeyClientToServer.subarray(0, c2s.keyLength), this.keys.ivClientToServer);
await this.encryptor.init();
if (!c2s.aead) { this.signer = new SSHHMAC(this.macC2S, this.keys.integrityKeyC2S); await this.signer.init(); }
}
private async enableInboundEncryption(): Promise<void> {
if (!this.keys) throw new Error('Session keys are not available');
const s2c = getCipherSpec(this.cipherS2C);
this.decryptor = s2c.mode === 'gcm'
? new SSHAESGCMCipher(this.keys.encKeyServerToClient.subarray(0, s2c.keyLength), this.keys.ivServerToClient)
: new SSHAESCTRCipher(this.keys.encKeyServerToClient.subarray(0, s2c.keyLength), this.keys.ivServerToClient);
await this.decryptor.init();
if (!s2c.aead) { this.verifier = new SSHHMAC(this.macS2C, this.keys.integrityKeyS2C); await this.verifier.init(); }
}
private async handleAuth(type: number, payload: Uint8Array): Promise<void> {
if (type === SSH_MSG_EXT_INFO) {
if (this.authRequestSent) throw new Error('Unexpected SSH extension information after authentication started');
this.serverSigAlgs = parseServerSigAlgs(payload);
return;
}
if (type === SSH_MSG_SERVICE_ACCEPT) {
const service = this.readString(payload, 1);
if (service.next !== payload.length || service.value !== 'ssh-userauth') throw new Error('Invalid SSH user authentication service acceptance');
if (this.authRequestSent) throw new Error('Duplicate SSH user authentication service acceptance');
const request = this.config.authMethod === 'publickey'
? await SSHAuth.buildPublicKeyAuthRequest(this.config.username, this.config.privateKey!, this.sessionId!, this.serverSigAlgs, false)
: SSHAuth.buildPasswordAuthRequest(this.config.username, this.config.password!);
this.authRequestSent = true;
this.config.password = undefined;
this.config.privateKey = undefined;
await this.sendEncrypted(request);
return;
}
if (type === SSH_MSG_USERAUTH_SUCCESS) {
if (payload.length !== 1) throw new Error('Malformed SSH authentication success');
if (!this.authRequestSent) throw new Error('SSH authentication completed before credentials were sent');
this.status('auth_success', 'SSH authentication succeeded; opening terminal');
this.phase = 'pty';
this.startKeepalive();
await this.sendEncrypted(this.channel.buildOpenSession(0));
return;
}
if (type === SSH_MSG_USERAUTH_FAILURE) {
if (!this.authRequestSent) throw new Error('SSH authentication failed before credentials were sent');
this.validateAuthFailure(payload);
throw new Error('SSH authentication failed');
}
}
private async handleChannel(type: number, payload: Uint8Array): Promise<void> {
if (type === SSH_MSG_CHANNEL_OPEN_CONFIRMATION) {
if (this.phase !== 'pty') throw new Error('Unexpected channel open confirmation');
this.channel.handleOpenConfirmation(payload);
this.pendingChannelRequest = 'pty';
await this.sendEncrypted(this.channel.buildPTYRequest(this.config.cols, this.config.rows, this.config.term));
return;
}
if (type === SSH_MSG_CHANNEL_OPEN_FAILURE) {
this.channel.handleOpenFailure(payload);
throw new Error('SSH server rejected the session channel');
}
if (type === SSH_MSG_CHANNEL_SUCCESS || type === SSH_MSG_CHANNEL_FAILURE) {
this.channel.handleRequestResult(payload);
const pending = this.pendingChannelRequest;
if (!pending || pending !== this.phase) throw new Error('Unexpected SSH channel request result');
this.pendingChannelRequest = null;
if (type === SSH_MSG_CHANNEL_FAILURE) throw new Error(`SSH server rejected the ${pending === 'pty' ? 'PTY' : 'shell'} request`);
if (pending === 'pty') {
this.phase = 'shell';
this.pendingChannelRequest = 'shell';
await this.sendEncrypted(this.channel.buildShellRequest());
this.shellTimer = setTimeout(() => this.markReady(), 3000);
} else this.markReady();
return;
}
if (type === SSH_MSG_CHANNEL_DATA) {
if (this.phase === 'shell') this.markReady();
const output = this.channel.handleChannelData(payload);
this.ws.send(output);
await this.adjustLocalWindow();
return;
}
if (type === SSH_MSG_CHANNEL_EXTENDED_DATA) {
if (this.phase === 'shell') this.markReady();
const output = this.channel.handleExtendedData(payload);
this.ws.send(output);
await this.adjustLocalWindow();
return;
}
if (type === SSH_MSG_CHANNEL_WINDOW_ADJUST) {
this.channel.handleWindowAdjust(payload);
void this.flushInput();
return;
}
if (type === SSH_MSG_CHANNEL_EOF) {
this.channel.handleEof(payload);
this.status('remote_eof', 'SSH server finished sending output');
return;
}
if (type === SSH_MSG_CHANNEL_CLOSE) {
this.channel.handleClose(payload);
if (!this.channel.hasSentClose()) await this.sendEncrypted(this.channel.buildClose());
this.status('session_ended', 'SSH session ended');
this.close(true);
return;
}
}
private markReady(): void {
if (this.phase === 'ready' || this.phase === 'closed') return;
if (this.shellTimer) clearTimeout(this.shellTimer);
this.shellTimer = null;
this.pendingChannelRequest = null;
this.phase = 'ready';
this.sendJson({
type: 'ready',
negotiated: { kex: this.kexName, cipherC2S: this.cipherC2S, cipherS2C: this.cipherS2C, macC2S: this.macC2S, macS2C: this.macS2C },
});
this.status('shell_ready', 'Shell is ready');
void this.flushInput();
}
private queueInput(data: Uint8Array): void {
if (data.length === 0) return;
if (this.phase !== 'ready') throw new Error('Terminal is not ready');
if (this.queuedBytes + data.length > MAX_QUEUED_INPUT) throw new Error('Terminal input queue limit exceeded');
this.inputQueue.push(data);
this.queuedBytes += data.length;
void this.flushInput();
}
private async flushInput(): Promise<void> {
if (this.flushingInput || this.phase !== 'ready') return;
this.flushingInput = true;
try {
while (this.inputQueue.length > 0 && this.phase === 'ready') {
const first = this.inputQueue[0];
const chunk = this.channel.takeChannelDataChunk(first, this.queueHeadOffset);
if (!chunk) return;
await this.sendChannelData(chunk);
this.queueHeadOffset += chunk.bytesConsumed;
this.queuedBytes -= chunk.bytesConsumed;
if (this.queueHeadOffset === first.length) {
this.inputQueue.shift();
this.queueHeadOffset = 0;
}
}
} finally {
this.flushingInput = false;
}
}
private async resize(cols: unknown, rows: unknown): Promise<void> {
if (typeof cols !== 'number' || typeof rows !== 'number' || !Number.isInteger(cols) || !Number.isInteger(rows) || cols < 10 || cols > 1000 || rows < 5 || rows > 1000) throw new Error('Invalid terminal size');
if (this.phase === 'ready') await this.sendEncrypted(this.channel.buildWindowChange(cols, rows));
}
private async adjustLocalWindow(): Promise<void> {
const amount = this.channel.takeLocalWindowAdjustment(LOCAL_WINDOW_THRESHOLD);
if (amount !== null) await this.sendEncrypted(this.channel.buildWindowAdjust(amount));
}
private async handleGlobalRequest(payload: Uint8Array): Promise<void> {
if (payload.length < 6) throw new Error('Malformed global request');
const nameLength = readUint32(payload, 1);
if (5 + nameLength >= payload.length) throw new Error('Malformed global request');
try { new TextDecoder('utf-8', { fatal: true }).decode(payload.subarray(5, 5 + nameLength)); }
catch { throw new Error('Malformed global request name'); }
if (payload[5 + nameLength] > 1) throw new Error('Malformed global request reply flag');
const wantsReply = payload[5 + nameLength] === 1;
if (wantsReply && this.encryptor) await this.sendEncrypted(new Uint8Array([SSH_MSG_REQUEST_FAILURE]));
}
private startKeepalive(): void {
this.keepaliveTimer = setInterval(() => {
if (this.phase === 'closed') return;
if (this.keepalivePending >= 3) {
this.fail(new Error('SSH keepalive timed out'), 'keepalive_timeout');
return;
}
this.keepalivePending++;
const payload = new Uint8Array(1 + 4 + KEEPALIVE_NAME.length + 1);
payload[0] = SSH_MSG_GLOBAL_REQUEST;
new DataView(payload.buffer).setUint32(1, KEEPALIVE_NAME.length, false);
payload.set(KEEPALIVE_NAME, 5);
payload[payload.length - 1] = 1;
void this.sendEncrypted(payload).catch((error) => this.fail(error, 'keepalive_failed'));
}, 25_000);
}
private async sendPlain(payload: Uint8Array): Promise<void> {
await this.serialSend(async () => {
const packet = await SSHPacketBuilder.build(payload, 8, null, this.sendSequence);
this.sendSequence = nextSequenceNumber(this.sendSequence);
await this.write(packet);
});
}
private async sendEncrypted(payload: Uint8Array): Promise<void> {
await this.serialSend(async () => {
if (!this.encryptor) throw new Error('SSH encryption is not initialized');
const spec = getCipherSpec(this.cipherC2S);
const packet = await SSHPacketBuilder.build(payload, spec.blockSize, (data, sequence, aad) => this.encryptor!.encrypt(data, sequence, aad), this.sendSequence, spec.aead, this.signer ? (data, sequence) => this.signer!.sign(data, sequence) : undefined);
this.sendSequence = nextSequenceNumber(this.sendSequence);
await this.write(packet);
});
}
private async sendChannelData(chunk: ChannelDataChunk): Promise<void> {
await this.serialSend(async () => {
if (!this.encryptor) throw new Error('SSH encryption is not initialized');
const spec = getCipherSpec(this.cipherC2S);
const packet = await SSHPacketBuilder.buildWithPayloadWriter(chunk.payloadLength, (target, offset) => this.channel.writeChannelDataPayload(target, offset, chunk.source, chunk.sourceOffset, chunk.bytesConsumed), spec.blockSize, (data, sequence, aad) => this.encryptor!.encrypt(data, sequence, aad), this.sendSequence, spec.aead, this.signer ? (data, sequence) => this.signer!.sign(data, sequence) : undefined);
this.sendSequence = nextSequenceNumber(this.sendSequence);
await this.write(packet);
});
}
private async serialSend(operation: () => Promise<void>): Promise<void> {
const next = this.sendChain.then(operation);
this.sendChain = next.catch(() => undefined);
await next;
}
private async write(data: Uint8Array): Promise<void> {
const writer = this.writer ?? this.socket.writable.getWriter();
this.writer = writer;
await writer.write(data);
}
private readBytes(bytes: Uint8Array, offset: number): { value: Uint8Array; next: number } {
if (offset + 4 > bytes.length) throw new Error('Malformed SSH key data');
const length = readUint32(bytes, offset);
if (length > bytes.length - offset - 4) throw new Error('Malformed SSH key data');
return { value: bytes.subarray(offset + 4, offset + 4 + length), next: offset + 4 + length };
}
private readString(bytes: Uint8Array, offset: number): { value: string; next: number } {
const field = this.readBytes(bytes, offset);
return { value: this.decoder.decode(field.value), next: field.next };
}
private sshEcdsaToRaw(signature: Uint8Array, coordinateBytes: number): Uint8Array {
const rField = this.readBytes(signature, 0);
const sField = this.readBytes(signature, rField.next);
if (sField.next !== signature.length) throw new Error('Malformed ECDSA signature trailing data');
const trim = (value: Uint8Array): Uint8Array => {
if (value.length === 0 || (value[0] & 0x80) !== 0) throw new Error('ECDSA signature integers must be positive');
if (value.length > 1 && value[0] === 0 && (value[1] & 0x80) === 0) throw new Error('ECDSA signature integer is not minimally encoded');
const normalized = value.length > 1 && value[0] === 0 ? value.subarray(1) : value;
if (normalized.every((byte) => byte === 0)) throw new Error('ECDSA signature integers must be non-zero');
return normalized;
};
const r = trim(rField.value); const s = trim(sField.value);
if (r.length > coordinateBytes || s.length > coordinateBytes) throw new Error('Invalid ECDSA signature');
const result = new Uint8Array(coordinateBytes * 2);
result.set(r, coordinateBytes - r.length);
result.set(s, coordinateBytes * 2 - s.length);
return result;
}
private base64(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/=+$/, '');
}
private base64UrlUnsigned(bytes: Uint8Array): string {
let start = 0;
while (start < bytes.length - 1 && bytes[start] === 0) start++;
return this.base64(bytes.subarray(start)).replace(/\+/g, '-').replace(/\//g, '_');
}
private validateDisconnect(payload: Uint8Array): void {
if (payload.length < 13) throw new Error('Malformed SSH disconnect message');
let offset = 5;
offset = this.readBytes(payload, offset).next;
offset = this.readBytes(payload, offset).next;
if (offset !== payload.length) throw new Error('Malformed SSH disconnect message');
}
private validateDebug(payload: Uint8Array): void {
if (payload.length < 10 || payload[1] > 1) throw new Error('Malformed SSH debug message');
let offset = 2;
offset = this.readBytes(payload, offset).next;
offset = this.readBytes(payload, offset).next;
if (offset !== payload.length) throw new Error('Malformed SSH debug message');
}
private validateAuthFailure(payload: Uint8Array): void {
if (payload.length < 6) throw new Error('Malformed SSH authentication failure');
const methodsLength = readUint32(payload, 1);
if (methodsLength > payload.length - 6 || methodsLength + 6 !== payload.length) {
throw new Error('Malformed SSH authentication failure');
}
}
private isHostKeyAlgorithmCompatible(negotiated: string | null, keyType: string): boolean {
if (!negotiated) return false;
if (negotiated === keyType) return true;
return keyType === 'ssh-rsa' && (negotiated === 'rsa-sha2-256' || negotiated === 'rsa-sha2-512');
}
private status(event: string, message: string): void { this.sendJson({ type: 'status', event, message }); }
private sendJson(value: unknown): void { if (this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(value)); }
private fail(error: unknown, event: string): void {
const message = error instanceof Error ? error.message : String(error);
this.sendJson({ type: 'error', event, message });
this.close();
}
}