Files
EdgeSSH/src/backend/session.ts
T

1529 lines
71 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_INFO_REQUEST,
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 { SFTPHandler } from './sftp-handler';
import { classifyHostKey } from '../ssh/host-key';
import { encodeString, readUint32, toBufferSource } from '../ssh/utils';
import { parseTopSnapshot } from './top-parser';
import { retainTrailingMarkerPrefix } from './process-framing';
type Cipher = SSHAESGCMCipher | SSHAESCTRCipher;
type Phase = 'version' | 'kex' | 'host-confirm' | 'auth' | 'pty' | 'shell' | 'ready' | 'closed';
interface PendingSFTPChannelOpen {
readonly channelID: number;
readonly channel: SSHChannel;
timeout: ReturnType<typeof setTimeout> | null;
cancelled: boolean;
}
interface PendingProcessChannelOpen {
readonly channelID: number;
readonly channel: SSHChannel;
timeout: ReturnType<typeof setTimeout> | null;
cancelled: boolean;
}
interface PendingProcessKillChannel {
readonly channelID: number;
readonly channel: SSHChannel;
readonly requestId: string;
readonly pid: number;
timeout: ReturnType<typeof setTimeout> | null;
cancelled: boolean;
openConfirmed: boolean;
decoder: TextDecoder;
stdout: string;
stderr: string;
stdoutBytes: number;
stderrBytes: number;
finalized: boolean;
}
const LOCAL_WINDOW_THRESHOLD = 512 * 1024;
const MAX_VERSION_BYTES = 8192;
const MAX_QUEUED_INPUT = 1024 * 1024;
const MAX_QUEUED_SFTP_UPLOAD = 1024 * 1024;
const SFTP_CHANNEL_OPEN_TIMEOUT_MS = 15_000;
const PROCESS_CHANNEL_OPEN_TIMEOUT_MS = 15_000;
const PROCESS_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
const PROCESS_KILL_CHANNEL_OPEN_TIMEOUT_MS = 15_000;
// Bound the per-kill stdout/stderr payload returned to the browser. `kill` usually produces no
// output on success, but a hostile or buggy server could dump arbitrary data through the exec
// channel; cap the bytes we keep per stream so one bad request cannot exhaust worker memory.
const PROCESS_KILL_MAX_BUFFER_BYTES = 4 * 1024;
const PROCESS_SNAPSHOT_MARKER = '__CF_WEBSSH_TOP_SNAPSHOT__';
// Octal escapes keep the delimiter itself out of the command line shown by top.
// `top` flags differ across platforms: Linux procps uses `-n 1` (1 iteration) + `-c` (full command line),
// but on FreeBSD `-n 1` means "show 1 process" and `-w` is unsupported — so the previous
// Linux-only fallback chain silently truncated FreeBSD output to a single process row.
// `-c` is required on Linux/macOS because their top defaults to showing just the program name;
// FreeBSD top shows the full command line by default.
// Width handling: without a tty, top (both procps and busybox) defaults to a narrow width and
// truncates long command lines with a '+' marker. procps supports `-w 0` (unlimited); busybox
// rejects `-w` and falls back to the plain form, but honors the COLUMNS env var — so we export
// COLUMNS=4096 and use a fallback chain on Linux to cover both implementations.
// Linux: top -b -c -n 1 -w 0 || top -b -c -n 1 || top -b -n 1
// FreeBSD: top -b -a -d 1 (batch, all processes, 1 iteration)
// macOS: top -l 1 -c -n 0 -s 0 (1 log sample, full command line, all processes, no delay)
const PROCESS_MONITOR_COMMAND = "LC_ALL=C LANG=C sh -c 'os=$(uname -s 2>/dev/null || echo Linux); export COLUMNS=4096; while :; do printf \"\\137\\137CF_WEBSSH_TOP_SNAPSHOT\\137\\137\\n\"; case \"$os\" in FreeBSD) top -b -a -d 1 2>/dev/null || exit 127;; Darwin) top -l 1 -c -n 0 -s 0 2>/dev/null || exit 127;; *) top -b -c -n 1 -w 0 2>/dev/null || top -b -c -n 1 2>/dev/null || top -b -n 1 2>/dev/null || exit 127;; esac; sleep 2; done'";
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 shellChannel = new SSHChannel();
private readonly channels = new Map<number, SSHChannel>([[0, this.shellChannel]]);
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 passwordAuthMethod: 'password' | 'keyboard-interactive' = 'password';
private keyboardInteractiveRounds = 0;
private keyboardInteractivePasswordSent = false;
private pendingChannelRequest: 'pty' | 'shell' | null = null;
private nextChannelID = 1;
private sftpChannel: SSHChannel | null = null;
private sftpHandler: SFTPHandler | null = null;
private sftpWebSocket: WebSocket | null = null;
private sftpAttachUrl = '';
private sftpTaskChain: Promise<void> = Promise.resolve();
private sftpQueuedUploadBytes = 0;
private pendingSFTPChannelOpen: PendingSFTPChannelOpen | null = null;
private processChannel: SSHChannel | null = null;
private processWebSocket: WebSocket | null = null;
private processAttachUrl = '';
private processBuffer = '';
private processDecoder = new TextDecoder();
private pendingProcessChannelOpen: PendingProcessChannelOpen | null = null;
// Concurrent `kill -TERM <pid>` requests, keyed by local channel ID. Each request gets its
// own exec channel so the top-monitor channel stays undisturbed.
private readonly pendingProcessKillChannels = new Map<number, PendingProcessKillChannel>();
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.sftpHandler?.dispose();
this.sftpHandler = null;
this.sftpChannel = null;
this.clearPendingSFTPChannelOpen();
this.clearPendingProcessChannelOpen();
this.processChannel = null;
this.processBuffer = '';
for (const kill of this.pendingProcessKillChannels.values()) {
if (kill.timeout) clearTimeout(kill.timeout);
}
this.pendingProcessKillChannels.clear();
this.channels.clear();
try { this.sftpWebSocket?.close(normal ? 1000 : 1011, normal ? 'SSH session closed' : 'SSH session failed'); } catch { /* already closed */ }
this.sftpWebSocket = null;
try { this.processWebSocket?.close(normal ? 1000 : 1011, normal ? 'SSH session closed' : 'SSH session failed'); } catch { /* already closed */ }
this.processWebSocket = null;
this.pendingHostConfirmation?.resolve(false);
this.pendingHostConfirmation = null;
this.config.password = undefined;
this.config.privateKey = undefined;
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.setRemoteVersion(line);
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 (!await this.verifyHostSignature(hostKey, signature, hash)) throw new Error('SSH host key signature verification failed');
const trust = classifyHostKey(this.config.expectedFingerprint, fingerprint);
if (trust === 'trusted') {
this.sendJson({ type: 'host_key', fingerprint, keyType, trusted: true });
} else if (!await this.confirmHostKey(fingerprint, keyType, trust === 'changed' ? this.config.expectedFingerprint : undefined)) {
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, expectedFingerprint?: 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, expectedFingerprint });
});
}
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)
: SSHAuth.buildPasswordAuthRequest(this.config.username, this.config.password!);
this.authRequestSent = true;
if (this.config.authMethod === 'publickey') this.config.privateKey = undefined;
await this.sendEncrypted(request);
return;
}
if (type === SSH_MSG_USERAUTH_INFO_REQUEST) {
if (this.config.authMethod !== 'password' || this.passwordAuthMethod !== 'keyboard-interactive' || !this.authRequestSent) {
throw new Error('Unexpected SSH keyboard-interactive challenge');
}
this.keyboardInteractiveRounds++;
if (this.keyboardInteractiveRounds > 16) throw new Error('Too many SSH keyboard-interactive challenge rounds');
const challenge = SSHAuth.parseKeyboardInteractiveChallenge(payload);
let responses: string[];
if (challenge.prompts.length === 0) {
responses = [];
} else {
if (this.keyboardInteractivePasswordSent || this.config.password === undefined) {
throw new Error('SSH keyboard-interactive requested credentials more than once');
}
responses = SSHAuth.passwordResponsesForChallenge(challenge, this.config.password, {
username: this.config.username,
host: this.config.host,
});
}
const response = SSHAuth.buildKeyboardInteractiveResponse(responses);
if (responses.length > 0) {
this.keyboardInteractivePasswordSent = true;
this.config.password = undefined;
}
await this.sendEncrypted(response);
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.config.password = undefined;
this.config.privateKey = undefined;
this.status('auth_success', 'SSH authentication succeeded; opening terminal');
this.phase = 'pty';
this.startKeepalive();
await this.sendEncrypted(this.shellChannel.buildOpenSession(0));
return;
}
if (type === SSH_MSG_USERAUTH_FAILURE) {
if (!this.authRequestSent) throw new Error('SSH authentication failed before credentials were sent');
const methods = this.validateAuthFailure(payload);
if (this.config.authMethod === 'password'
&& this.passwordAuthMethod === 'password'
&& this.config.password !== undefined
&& methods.includes('keyboard-interactive')) {
this.passwordAuthMethod = 'keyboard-interactive';
await this.sendEncrypted(SSHAuth.buildKeyboardInteractiveAuthRequest(this.config.username));
return;
}
this.config.password = undefined;
throw new Error('SSH authentication failed');
}
}
private async handleChannel(type: number, payload: Uint8Array): Promise<void> {
if (payload.length < 5) throw new Error('Malformed SSH channel message');
const channelID = readUint32(payload, 1);
const channel = this.channels.get(channelID);
if (!channel) throw new Error('SSH channel message has an unknown recipient');
const isShell = channel === this.shellChannel;
if (type === SSH_MSG_CHANNEL_OPEN_CONFIRMATION) {
channel.handleOpenConfirmation(payload);
if (isShell) {
if (this.phase !== 'pty') throw new Error('Unexpected shell channel open confirmation');
this.pendingChannelRequest = 'pty';
await this.sendEncrypted(channel.buildPTYRequest(this.config.cols, this.config.rows, this.config.term));
} else if (this.pendingProcessKillChannels.has(channelID)) {
const kill = this.pendingProcessKillChannels.get(channelID)!;
kill.openConfirmed = true;
if (kill.timeout) { clearTimeout(kill.timeout); kill.timeout = null; }
if (kill.cancelled || this.processWebSocket === null) {
await this.sendAuxiliaryChannelClose(channel);
} else {
try {
// The PID is a validated safe integer (1..2^31-1); interpolation is safe because
// its decimal representation cannot contain shell metacharacters.
await this.sendEncrypted(channel.buildExecRequest(`kill -TERM ${kill.pid}`));
} catch (error) {
this.finalizeProcessKill(kill, error instanceof Error ? error.message : String(error));
await this.sendAuxiliaryChannelClose(channel);
}
}
} else if (channel === this.sftpChannel && this.sftpHandler && !this.pendingSFTPChannelOpen?.cancelled) {
try {
await this.sendEncrypted(channel.buildSubsystemRequest('sftp'));
} catch (error) {
this.clearPendingSFTPChannelOpen(channel);
this.sftpHandler.dispose();
this.sftpHandler = null;
this.sftpChannel = null;
await this.sendSFTPChannelClose(channel);
throw error;
}
} else if (channel === this.processChannel && this.processWebSocket && !this.pendingProcessChannelOpen?.cancelled) {
try {
await this.sendEncrypted(channel.buildExecRequest(PROCESS_MONITOR_COMMAND));
} catch (error) {
this.clearPendingProcessChannelOpen(channel);
this.processChannel = null;
await this.sendAuxiliaryChannelClose(channel);
throw error;
}
} else {
// An attachment WebSocket may close while its channel is opening.
this.clearPendingSFTPChannelOpen(channel);
this.clearPendingProcessChannelOpen(channel);
await this.sendAuxiliaryChannelClose(channel);
}
return;
}
if (type === SSH_MSG_CHANNEL_OPEN_FAILURE) {
channel.handleOpenFailure(payload);
this.clearPendingSFTPChannelOpen(channel);
this.clearPendingProcessChannelOpen(channel);
this.channels.delete(channelID);
if (isShell) throw new Error('SSH server rejected the session channel');
if (channel === this.sftpChannel) {
this.sftpHandler?.dispose();
this.sftpHandler = null;
this.sftpChannel = null;
this.sendSFTPError('init', 'SSH server rejected the SFTP channel');
} else if (channel === this.processChannel) {
this.processChannel = null;
this.sendProcessError('SSH server rejected the process-monitor channel');
} else {
const kill = this.pendingProcessKillChannels.get(channelID);
if (kill) this.finalizeProcessKill(kill, 'SSH server rejected the kill channel');
}
return;
}
if (type === SSH_MSG_CHANNEL_SUCCESS || type === SSH_MSG_CHANNEL_FAILURE) {
channel.handleRequestResult(payload);
this.clearPendingSFTPChannelOpen(channel);
if (isShell) {
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(channel.buildShellRequest());
this.shellTimer = setTimeout(() => this.markReady(), 3000);
} else this.markReady();
} else if (channel === this.sftpChannel && type === SSH_MSG_CHANNEL_SUCCESS) {
const handler = this.sftpHandler;
if (handler) {
void handler.initialize().then((ready) => {
if (!ready && this.sftpHandler === handler) void this.closeSFTPChannel();
});
}
} else if (channel === this.sftpChannel) {
this.sftpHandler?.dispose();
this.sftpHandler = null;
this.sftpChannel = null;
this.sendSFTPError('init', 'SSH server rejected the SFTP subsystem');
await this.sendSFTPChannelClose(channel);
} else if (channel === this.processChannel) {
this.clearPendingProcessChannelOpen(channel);
if (type === SSH_MSG_CHANNEL_FAILURE) {
this.processChannel = null;
this.sendProcessError('SSH server rejected the top command');
await this.sendAuxiliaryChannelClose(channel);
} else {
this.sendProcessJson({ type: 'process_ready' });
}
}
return;
}
if (type === SSH_MSG_CHANNEL_DATA) {
const output = channel.handleChannelData(payload);
if (isShell) {
if (this.phase === 'shell') this.markReady();
this.ws.send(output);
} else if (channel === this.sftpChannel && this.sftpHandler) {
try {
this.sftpHandler.feed(output);
} catch (error) {
this.sendSFTPError('protocol', error instanceof Error ? error.message : String(error));
await this.closeSFTPChannel();
}
} else if (channel === this.processChannel) {
this.consumeProcessOutput(output);
} else if (this.pendingProcessKillChannels.has(channelID)) {
this.appendKillOutput(this.pendingProcessKillChannels.get(channelID)!, output, false);
}
await this.adjustLocalWindow(channel);
return;
}
if (type === SSH_MSG_CHANNEL_EXTENDED_DATA) {
const output = channel.handleExtendedData(payload);
if (isShell) {
if (this.phase === 'shell') this.markReady();
this.ws.send(output);
} else if (channel === this.sftpChannel) {
this.sendSFTPError('protocol', new TextDecoder().decode(output) || 'SFTP channel reported an error');
} else if (channel === this.processChannel) {
const message = new TextDecoder().decode(output).trim();
if (message) this.sendProcessError(message.slice(0, 512));
} else if (this.pendingProcessKillChannels.has(channelID)) {
this.appendKillOutput(this.pendingProcessKillChannels.get(channelID)!, output, true);
}
await this.adjustLocalWindow(channel);
return;
}
if (type === SSH_MSG_CHANNEL_WINDOW_ADJUST) {
channel.handleWindowAdjust(payload);
if (isShell) void this.flushInput();
else if (channel === this.sftpChannel) this.sftpHandler?.onWindowAdjust();
return;
}
if (type === SSH_MSG_CHANNEL_EOF) {
channel.handleEof(payload);
if (isShell) this.status('remote_eof', 'SSH server finished sending output');
else if (channel === this.sftpChannel) this.sftpHandler?.onClosed();
else if (channel === this.processChannel) this.flushProcessBuffer();
return;
}
if (type === SSH_MSG_CHANNEL_CLOSE) {
channel.handleClose(payload);
if (!channel.hasSentClose()) await this.sendEncrypted(channel.buildClose());
if (isShell) {
this.status('session_ended', 'SSH session ended');
this.close(true);
} else {
this.clearPendingSFTPChannelOpen(channel);
this.channels.delete(channelID);
if (channel === this.sftpChannel) {
this.sftpHandler?.onClosed();
this.sftpHandler = null;
this.sftpChannel = null;
} else if (channel === this.processChannel) {
this.clearPendingProcessChannelOpen(channel);
this.flushProcessBuffer();
this.processChannel = null;
this.sendProcessError('The process monitor stopped');
} else {
const kill = this.pendingProcessKillChannels.get(channelID);
if (kill) this.finalizeProcessKill(kill);
}
}
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');
if (this.sftpAttachUrl) this.sendJson({ type: 'sftp_attach', url: this.sftpAttachUrl });
if (this.processAttachUrl) this.sendJson({ type: 'process_attach', url: this.processAttachUrl });
void this.flushInput();
}
setSFTPAttachUrl(url: string): void {
if (!/^\/api\/sftp\?/.test(url)) throw new Error('Invalid SFTP attach URL');
this.sftpAttachUrl = url;
}
setProcessAttachUrl(url: string): void {
if (!/^\/api\/processes\?/.test(url)) throw new Error('Invalid process attach URL');
this.processAttachUrl = url;
}
attachProcessWebSocket(ws: WebSocket): void {
if (this.phase === 'closed' || this.processWebSocket) {
try { ws.close(1008, 'Process monitor is unavailable'); } catch { /* already closed */ }
return;
}
this.processWebSocket = ws;
}
detachProcessWebSocket(ws: WebSocket): void {
if (this.processWebSocket !== ws) return;
this.processWebSocket = null;
void this.closeProcessChannel();
// Outstanding kill requests cannot deliver their result back without the monitor socket;
// close them so the server releases the SSH channels.
for (const kill of [...this.pendingProcessKillChannels.values()]) {
kill.cancelled = true;
if (kill.timeout) { clearTimeout(kill.timeout); kill.timeout = null; }
if (kill.openConfirmed) {
if (kill.channel.isOpen() && !kill.channel.hasSentClose()) void this.sendAuxiliaryChannelClose(kill.channel);
this.finalizeProcessKill(kill, 'Process monitor disconnected');
} else {
this.pendingProcessKillChannels.delete(kill.channelID);
this.channels.delete(kill.channelID);
if (kill.timeout) { clearTimeout(kill.timeout); kill.timeout = null; }
}
}
}
async handleProcessClientMessage(message: string | ArrayBuffer): Promise<void> {
if (this.phase === 'closed' || !this.processWebSocket) return;
if (message instanceof ArrayBuffer || message.length > 4096) throw new Error('Invalid process-monitor message');
let decoded: unknown;
try { decoded = JSON.parse(message); } catch { throw new Error('Invalid process-monitor JSON'); }
if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) throw new Error('Invalid process-monitor message');
const value = decoded as Record<string, unknown>;
if (value.type === 'ping') { this.sendProcessJson({ type: 'pong' }); return; }
if (value.type === 'process_start') { await this.openProcessChannel(); return; }
if (value.type === 'process_stop') { await this.closeProcessChannel(); return; }
if (value.type === 'process_kill') {
const pid = value.pid;
if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0 || pid > 2_147_483_647) {
throw new Error('Invalid process-kill request: PID out of range');
}
const requestId = value.requestId;
if (typeof requestId !== 'string' || requestId.length === 0 || requestId.length > 64) {
throw new Error('Invalid process-kill request: requestId is missing or too long');
}
await this.openProcessKillChannel(pid, requestId);
return;
}
throw new Error('Unsupported process-monitor message');
}
private async openProcessKillChannel(pid: number, requestId: string): Promise<void> {
if (!this.processWebSocket) throw new Error('Process-monitor WebSocket is not attached');
if (this.phase !== 'ready') throw new Error('SSH connection is not ready');
const channelID = this.nextChannelID++;
const channel = new SSHChannel();
const pending: PendingProcessKillChannel = {
channelID,
channel,
requestId,
pid,
timeout: null,
cancelled: false,
openConfirmed: false,
decoder: new TextDecoder(),
stdout: '',
stderr: '',
stdoutBytes: 0,
stderrBytes: 0,
finalized: false,
};
this.channels.set(channelID, channel);
this.pendingProcessKillChannels.set(channelID, pending);
try {
await this.sendEncrypted(channel.buildOpenSession(channelID));
pending.timeout = setTimeout(() => this.expirePendingProcessKill(pending), PROCESS_KILL_CHANNEL_OPEN_TIMEOUT_MS);
} catch (error) {
this.clearPendingProcessKill(channel);
this.channels.delete(channelID);
throw error;
}
}
private async openProcessChannel(): Promise<void> {
if (!this.processWebSocket) throw new Error('Process-monitor WebSocket is not attached');
if (this.phase !== 'ready') throw new Error('SSH connection is not ready');
if (this.pendingProcessChannelOpen?.cancelled) throw new Error('The previous process channel is still closing');
if (this.processChannel) {
this.sendProcessJson({ type: 'process_ready' });
return;
}
const channelID = this.nextChannelID++;
const channel = new SSHChannel();
const pending: PendingProcessChannelOpen = { channelID, channel, timeout: null, cancelled: false };
this.channels.set(channelID, channel);
this.processChannel = channel;
this.processBuffer = '';
this.processDecoder = new TextDecoder();
this.pendingProcessChannelOpen = pending;
try {
await this.sendEncrypted(channel.buildOpenSession(channelID));
if (this.pendingProcessChannelOpen === pending) {
pending.timeout = setTimeout(() => this.expirePendingProcessChannelOpen(pending), PROCESS_CHANNEL_OPEN_TIMEOUT_MS);
}
} catch (error) {
this.clearPendingProcessChannelOpen(channel);
this.channels.delete(channelID);
this.processChannel = null;
throw error;
}
}
private async closeProcessChannel(): Promise<void> {
const channel = this.processChannel;
const pending = this.pendingProcessChannelOpen;
if (pending?.channel === channel) pending.cancelled = true;
this.processChannel = null;
this.processBuffer = '';
if (!channel?.isOpen()) return;
await this.sendAuxiliaryChannelClose(channel);
}
private consumeProcessOutput(data: Uint8Array): void {
this.processBuffer += this.processDecoder.decode(data, { stream: true });
if (this.processBuffer.length > PROCESS_MAX_BUFFER_BYTES) {
const marker = this.processBuffer.lastIndexOf(PROCESS_SNAPSHOT_MARKER);
this.processBuffer = marker >= 0
? this.processBuffer.slice(marker)
: retainTrailingMarkerPrefix(this.processBuffer, PROCESS_SNAPSHOT_MARKER);
this.sendProcessError('Process snapshot exceeded the buffer limit');
return;
}
while (true) {
const first = this.processBuffer.indexOf(PROCESS_SNAPSHOT_MARKER);
if (first < 0) {
this.processBuffer = retainTrailingMarkerPrefix(this.processBuffer, PROCESS_SNAPSHOT_MARKER);
return;
}
if (first > 0) this.processBuffer = this.processBuffer.slice(first);
const next = this.processBuffer.indexOf(PROCESS_SNAPSHOT_MARKER, PROCESS_SNAPSHOT_MARKER.length);
if (next < 0) return;
this.emitProcessSnapshot(this.processBuffer.slice(PROCESS_SNAPSHOT_MARKER.length, next));
this.processBuffer = this.processBuffer.slice(next);
}
}
private flushProcessBuffer(): void {
this.processBuffer += this.processDecoder.decode();
const marker = this.processBuffer.lastIndexOf(PROCESS_SNAPSHOT_MARKER);
if (marker >= 0) this.emitProcessSnapshot(this.processBuffer.slice(marker + PROCESS_SNAPSHOT_MARKER.length));
this.processBuffer = '';
this.processDecoder = new TextDecoder();
}
private emitProcessSnapshot(raw: string): void {
const snapshot = parseTopSnapshot(raw);
if (snapshot) this.sendProcessJson({ type: 'process_snapshot', ...snapshot });
}
private expirePendingProcessChannelOpen(expected: PendingProcessChannelOpen): void {
if (this.pendingProcessChannelOpen !== expected) return;
this.pendingProcessChannelOpen = null;
expected.timeout = null;
expected.cancelled = true;
if (this.processChannel === expected.channel) this.processChannel = null;
this.sendProcessError('Timed out opening the process-monitor channel');
if (expected.channel.isOpen()) void this.sendAuxiliaryChannelClose(expected.channel);
try { this.processWebSocket?.close(1011, 'Process channel setup timed out'); } catch { /* already closed */ }
}
private clearPendingProcessChannelOpen(channel?: SSHChannel): void {
const pending = this.pendingProcessChannelOpen;
if (!pending || (channel && pending.channel !== channel)) return;
if (pending.timeout) clearTimeout(pending.timeout);
pending.timeout = null;
this.pendingProcessChannelOpen = null;
}
private appendKillOutput(kill: PendingProcessKillChannel, output: Uint8Array, isStderr: boolean): void {
if (kill.finalized) return;
const text = kill.decoder.decode(output, { stream: true });
if (!text) return;
if (isStderr) {
const remaining = PROCESS_KILL_MAX_BUFFER_BYTES - kill.stderrBytes;
if (remaining <= 0) return;
const slice = text.length > remaining ? text.slice(0, remaining) : text;
kill.stderr += slice;
kill.stderrBytes += slice.length;
} else {
const remaining = PROCESS_KILL_MAX_BUFFER_BYTES - kill.stdoutBytes;
if (remaining <= 0) return;
const slice = text.length > remaining ? text.slice(0, remaining) : text;
kill.stdout += slice;
kill.stdoutBytes += slice.length;
}
}
private finalizeProcessKill(kill: PendingProcessKillChannel, errorMessage?: string): void {
if (kill.finalized) return;
kill.finalized = true;
if (kill.timeout) { clearTimeout(kill.timeout); kill.timeout = null; }
// Flush any decoder tail so callers see a complete (possibly empty) string.
if (!errorMessage) {
const tail = kill.decoder.decode();
if (tail) {
const remaining = PROCESS_KILL_MAX_BUFFER_BYTES - kill.stdoutBytes;
if (remaining > 0) {
const slice = tail.length > remaining ? tail.slice(0, remaining) : tail;
kill.stdout += slice;
kill.stdoutBytes += slice.length;
}
}
}
const stdout = kill.stdout;
const stderr = errorMessage ?? kill.stderr;
const ok = errorMessage === undefined && stderr.trim() === '';
// The SSH RFC exposes the exit code via a server-sent channel request before close; this
// session does not currently decode that request, so we cannot report the real status here.
// Surface "null" so the client knows we fell back to the stderr heuristic.
const exitStatus: number | null = null;
this.pendingProcessKillChannels.delete(kill.channelID);
this.channels.delete(kill.channelID);
this.sendProcessJson({
type: 'process_kill_result',
pid: kill.pid,
requestId: kill.requestId,
ok,
exitStatus,
stdout,
stderr,
});
}
private clearPendingProcessKill(channel?: SSHChannel): void {
if (!channel) return;
const kill = this.pendingProcessKillChannels.get(channel.getLocalChannelID());
if (!kill || kill.channel !== channel) return;
if (kill.timeout) { clearTimeout(kill.timeout); kill.timeout = null; }
kill.cancelled = true;
}
private expirePendingProcessKill(expected: PendingProcessKillChannel): void {
const current = this.pendingProcessKillChannels.get(expected.channelID);
if (current !== expected) return;
expected.timeout = null;
expected.cancelled = true;
this.finalizeProcessKill(expected, 'Timed out opening the kill channel');
if (expected.channel.isOpen()) void this.sendAuxiliaryChannelClose(expected.channel);
}
private sendProcessJson(value: unknown): void {
if (this.processWebSocket?.readyState === WebSocket.OPEN) this.processWebSocket.send(JSON.stringify(value));
}
private sendProcessError(message: string): void {
this.sendProcessJson({ type: 'process_error', message });
}
attachSFTPWebSocket(ws: WebSocket): void {
if (this.phase === 'closed' || this.sftpWebSocket) {
try { ws.close(1008, 'SFTP connection is unavailable'); } catch { /* already closed */ }
return;
}
this.sftpWebSocket = ws;
}
detachSFTPWebSocket(ws: WebSocket): void {
if (this.sftpWebSocket !== ws) return;
this.sftpWebSocket = null;
void this.closeSFTPChannel();
}
async handleSFTPClientMessage(message: string | ArrayBuffer): Promise<void> {
if (this.phase === 'closed' || !this.sftpWebSocket) return;
if (message instanceof ArrayBuffer) {
if (message.byteLength === 0 || message.byteLength > 64 * 1024) {
this.sendSFTPError('upload', 'Invalid upload chunk');
return;
}
if (this.sftpQueuedUploadBytes + message.byteLength > MAX_QUEUED_SFTP_UPLOAD) {
this.sendSFTPError('upload', 'Upload queue limit exceeded');
return;
}
const chunk = new Uint8Array(message);
this.sftpQueuedUploadBytes += chunk.byteLength;
await this.queueSFTPTask('upload', undefined, async () => {
try {
if (!this.sftpHandler) throw new Error('SFTP is not initialized');
await this.sftpHandler.uploadChunk(chunk);
} finally {
this.sftpQueuedUploadBytes = Math.max(0, this.sftpQueuedUploadBytes - chunk.byteLength);
}
});
return;
}
if (message.length > 16 * 1024) {
this.sendSFTPError('protocol', 'SFTP control message is too large');
return;
}
let value: Record<string, unknown>;
try {
const parsed = JSON.parse(message) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error();
value = parsed as Record<string, unknown>;
} catch {
this.sendSFTPError('protocol', 'Invalid SFTP control message');
return;
}
if (value.type === 'ping') { this.sendSFTPJson({ type: 'pong' }); return; }
if (value.type === 'sftp_download_cancel') {
try {
const requestId = this.requestId(value.requestId);
if (!this.sftpHandler) throw new Error('SFTP is not initialized');
this.sftpHandler.cancelDownload(requestId);
} catch (error) {
this.sendSFTPError('download', error instanceof Error ? error.message : String(error));
}
return;
}
const operation = typeof value.type === 'string' ? value.type.replace(/^sftp_/, '') : 'protocol';
await this.queueSFTPTask(operation, this.requestId(value.requestId, false), () => this.handleSFTPControl(value));
}
private async handleSFTPControl(value: Record<string, unknown>): Promise<void> {
const type = typeof value.type === 'string' ? value.type : '';
if (type === 'sftp_close') { await this.closeSFTPChannel(); return; }
if (this.phase !== 'ready') throw new Error('SSH connection is not ready');
if (type === 'sftp_init') {
await this.openSFTPChannel();
return;
}
if (!this.sftpHandler) throw new Error('SFTP is not initialized');
const requestId = this.requestId(value.requestId);
if (type === 'sftp_list') return this.sftpHandler.list(requestId, this.path(value.path));
if (type === 'sftp_download') return this.sftpHandler.download(requestId, this.path(value.path));
if (type === 'sftp_upload_start') {
if (typeof value.size !== 'number') throw new Error('Invalid upload size');
if (typeof value.overwrite !== 'boolean') throw new Error('Invalid upload overwrite option');
return this.sftpHandler.startUpload(requestId, this.path(value.path), value.size, value.overwrite);
}
if (type === 'sftp_upload_end') return this.sftpHandler.finishUpload(requestId);
if (type === 'sftp_upload_cancel') return this.sftpHandler.cancelUpload(requestId);
if (type === 'sftp_delete') return this.sftpHandler.remove(requestId, this.path(value.path), false);
if (type === 'sftp_rmdir') return this.sftpHandler.remove(requestId, this.path(value.path), true);
if (type === 'sftp_mkdir') return this.sftpHandler.mkdir(requestId, this.path(value.path));
if (type === 'sftp_rename') return this.sftpHandler.rename(requestId, this.path(value.oldPath), this.path(value.newPath));
throw new Error('Unsupported SFTP control message');
}
private async openSFTPChannel(): Promise<void> {
if (!this.sftpWebSocket) throw new Error('SFTP WebSocket is not attached');
if (this.pendingSFTPChannelOpen?.cancelled) throw new Error('The previous SFTP channel is still closing');
if (this.sftpHandler) {
if (this.sftpHandler.isReady()) this.sftpHandler.announceReady();
else this.sendSFTPError('init', 'SFTP initialization is already in progress');
return;
}
const channelID = this.nextChannelID++;
const channel = new SSHChannel();
this.channels.set(channelID, channel);
this.sftpChannel = channel;
this.sftpHandler = new SFTPHandler(
channel,
(target, chunk) => this.sendChannelData(target, chunk),
(message) => this.sendSFTPJson(message),
(data) => this.sendSFTPBinary(data),
);
const pending: PendingSFTPChannelOpen = {
channelID,
channel,
cancelled: false,
timeout: null,
};
this.pendingSFTPChannelOpen = pending;
try {
await this.sendEncrypted(channel.buildOpenSession(channelID));
if (this.pendingSFTPChannelOpen === pending) {
pending.timeout = setTimeout(() => this.expirePendingSFTPChannelOpen(pending), SFTP_CHANNEL_OPEN_TIMEOUT_MS);
}
} catch (error) {
this.clearPendingSFTPChannelOpen(channel);
this.channels.delete(channelID);
this.sftpHandler.dispose();
this.sftpHandler = null;
this.sftpChannel = null;
throw error;
}
}
private async closeSFTPChannel(): Promise<void> {
const handler = this.sftpHandler;
if (!handler) return;
const channel = this.sftpChannel;
const pending = this.pendingSFTPChannelOpen;
if (pending?.channel === channel) pending.cancelled = true;
this.sftpHandler = null;
this.sftpChannel = null;
handler.dispose();
if (!channel) return;
if (!channel.isOpen()) return;
await this.sendSFTPChannelClose(channel);
}
private async queueSFTPTask(operation: string, requestId: string | undefined, task: () => Promise<void>): Promise<void> {
const run = this.sftpTaskChain.then(task);
const handled = run.catch((error) => {
this.sendSFTPError(operation, error instanceof Error ? error.message : String(error), requestId);
});
this.sftpTaskChain = handled;
await handled;
}
private expirePendingSFTPChannelOpen(expected: PendingSFTPChannelOpen): void {
if (this.pendingSFTPChannelOpen !== expected) return;
this.pendingSFTPChannelOpen = null;
expected.timeout = null;
expected.cancelled = true;
if (this.sftpChannel === expected.channel) {
this.sftpHandler?.dispose();
this.sftpHandler = null;
this.sftpChannel = null;
}
this.sendSFTPError('init', 'Timed out opening the SFTP channel');
if (expected.channel.isOpen()) void this.sendSFTPChannelClose(expected.channel);
try { this.sftpWebSocket?.close(1011, 'SFTP channel setup timed out'); } catch { /* already closed */ }
}
private async sendSFTPChannelClose(channel: SSHChannel): Promise<void> {
await this.sendAuxiliaryChannelClose(channel);
}
private async sendAuxiliaryChannelClose(channel: SSHChannel): Promise<void> {
if (!channel.isOpen() || channel.hasSentClose()) return;
try {
await this.sendEncrypted(channel.buildEof());
await this.sendEncrypted(channel.buildClose());
} catch { /* The SSH session may already be closing. */ }
}
private clearPendingSFTPChannelOpen(channel?: SSHChannel): void {
const pending = this.pendingSFTPChannelOpen;
if (!pending || (channel && pending.channel !== channel)) return;
if (pending.timeout) clearTimeout(pending.timeout);
pending.timeout = null;
this.pendingSFTPChannelOpen = null;
}
private requestId(value: unknown): string;
private requestId(value: unknown, required: true): string;
private requestId(value: unknown, required: false): string | undefined;
private requestId(value: unknown, required = true): string | undefined {
if (typeof value === 'string' && /^[A-Za-z0-9._:-]{1,64}$/.test(value)) return value;
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return String(value);
if (!required) return undefined;
throw new Error('Invalid SFTP request identifier');
}
private path(value: unknown): string {
if (typeof value !== 'string') throw new Error('Invalid remote path');
return value;
}
private sendSFTPJson(value: unknown): void {
if (this.sftpWebSocket?.readyState === WebSocket.OPEN) this.sftpWebSocket.send(JSON.stringify(value));
}
private sendSFTPBinary(data: Uint8Array): void {
if (this.sftpWebSocket?.readyState === WebSocket.OPEN) this.sftpWebSocket.send(data);
}
private sendSFTPError(operation: string, message: string, requestId?: string): void {
this.sendSFTPJson({ type: 'sftp_error', operation, message, requestId });
}
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.shellChannel.takeChannelDataChunk(first, this.queueHeadOffset);
if (!chunk) return;
await this.sendChannelData(this.shellChannel, 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.shellChannel.buildWindowChange(cols, rows));
}
private async adjustLocalWindow(channel: SSHChannel): Promise<void> {
const amount = channel.takeLocalWindowAdjustment(LOCAL_WINDOW_THRESHOLD);
if (amount !== null) await this.sendEncrypted(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(channel: SSHChannel, 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) => 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);
try {
return { value: new TextDecoder('utf-8', { fatal: true }).decode(field.value), next: field.next };
} catch {
throw new Error('Malformed SSH text field');
}
}
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): string[] {
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');
}
const partialSuccess = payload[payload.length - 1];
if (partialSuccess > 1) throw new Error('Malformed SSH authentication partial-success flag');
if (partialSuccess === 1) throw new Error('Multi-factor SSH authentication is not supported');
let methods: string;
try { methods = new TextDecoder('utf-8', { fatal: true }).decode(payload.subarray(5, 5 + methodsLength)); }
catch { throw new Error('Malformed SSH authentication methods'); }
if (methods && methods.split(',').some((method) => method.length === 0 || !/^[A-Za-z0-9@._+-]+$/.test(method))) {
throw new Error('Malformed SSH authentication methods');
}
return methods ? methods.split(',') : [];
}
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();
}
}