@microsoft/dev-tunnels-ssh
Version:
SSH library for Dev Tunnels
527 lines • 26.7 kB
JavaScript
"use strict";
//
// Copyright (c) Microsoft Corporation. All rights reserved.
//
Object.defineProperty(exports, "__esModule", { value: true });
exports.SshProtocol = void 0;
const buffer_1 = require("buffer");
const queue_1 = require("../util/queue");
const semaphore_1 = require("../util/semaphore");
const sshMessage_1 = require("../messages/sshMessage");
const sshData_1 = require("../io/sshData");
const transportMessages_1 = require("../messages/transportMessages");
const kexMessages_1 = require("../messages/kexMessages");
const connectionMessages_1 = require("../messages/connectionMessages");
const errors_1 = require("../errors");
const trace_1 = require("../trace");
class SequencedMessage {
constructor(sequence, message) {
this.sequence = sequence;
this.message = message;
}
}
/**
* Implements the base SSH protocol (sending and receiving messages) over a Stream.
*/
class SshProtocol {
constructor(stream, config, metrics, trace) {
this.config = config;
this.metrics = metrics;
this.trace = trace;
this.sessionSemaphore = new semaphore_1.Semaphore(1);
this.inboundPacketSequence = 0;
this.outboundPacketSequence = 0;
this.inboundFlow = 0;
this.outboundFlow = 0;
// Sent messages are kept for a short time, until the other side acknowledges
// that they have been received. This enables re-sending lost messages on reconnect.
this.recentSentMessages = new queue_1.Queue();
// Initialize buffers that are re-used for each sent/received message.
// The buffers will be automatically expanded as necessary.
this.sendWriter = new sshData_1.SshDataWriter(buffer_1.Buffer.alloc(1024));
this.receiveWriter = new sshData_1.SshDataWriter(buffer_1.Buffer.alloc(1024));
/* @internal */
this.traceChannelData = false;
this.extensions = null;
this.kexService = null;
this.algorithms = null;
this.messageContext = null;
this.outgoingMessagesHaveLatencyInfo = false;
this.incomingMessagesHaveLatencyInfo = false;
this.outgoingMessagesHaveReconnectInfo = false;
this.incomingMessagesHaveReconnectInfo = false;
this.stream = stream;
this.traceChannelData = config.traceChannelData;
}
get lastIncomingSequence() {
return this.inboundPacketSequence - 1;
}
getSentMessages(startingSequenceNumber) {
if (startingSequenceNumber === this.outboundPacketSequence + 1) {
// The recipient is already up-to-date.
return [];
}
if (this.recentSentMessages.size > 0 &&
startingSequenceNumber < this.recentSentMessages.peek().sequence) {
// The cached recent messages do not go back as far as the requested sequence number.
// This should never happen because messages are not dropped from this list until
// the other side acknowledges they have been received, so they should not be
// requested again after reconnecting.
return null;
}
// Return all messages starting with the requested sequence number.
// Exclude key exchange messages because they cannot be retransmitted; a reconnected
// session will do key exchange separately. Also exclude any disconnect messages that
// may have been attempted when the connection was lost.
const messagesToRetransmit = new Array();
for (const sequencedMessage of this.recentSentMessages) {
if (sequencedMessage.sequence >= startingSequenceNumber) {
const message = sequencedMessage.message;
if (!(message instanceof kexMessages_1.KeyExchangeMessage || message instanceof transportMessages_1.DisconnectMessage)) {
messagesToRetransmit.push(message);
}
}
}
return messagesToRetransmit;
}
async writeProtocolVersion(version, cancellation) {
const stream = this.stream;
if (!stream)
throw new Error('SSH session disconnected.');
const data = buffer_1.Buffer.from(version + '\r\n');
await stream.write(data, cancellation);
this.metrics.addMessageSent(data.length);
return Promise.resolve();
}
async readProtocolVersion(cancellation) {
const stream = this.stream;
if (!stream)
throw new Error('SSH session disconnected.');
// http://tools.ietf.org/html/rfc4253#section-4.2
const buffer = buffer_1.Buffer.alloc(255);
let lineCount = 0;
for (let i = 0; i < buffer.length; i++) {
const byteBuffer = await stream.read(1, cancellation);
if (!byteBuffer) {
break;
}
buffer[i] = byteBuffer[0];
const carriageReturn = 0x0d;
const lineFeed = 0x0a;
if (i > 0 && buffer[i - 1] === carriageReturn && buffer[i] === lineFeed) {
const line = buffer.toString('utf8', 0, i - 1);
if (line.startsWith('SSH-')) {
this.metrics.addMessageReceived(i + 1);
return line;
}
else if (lineCount > 20) {
// Give up if a version string was not found after 20 lines.
break;
}
else {
// Ignore initial lines before the version line.
lineCount++;
i = -1;
}
}
}
throw new errors_1.SshConnectionError('Failed to read the protocol version', transportMessages_1.SshDisconnectReason.protocolError);
}
async handleNewKeys(cancellation) {
try {
await this.sessionSemaphore.wait(cancellation);
this.inboundFlow = 0;
this.outboundFlow = 0;
this.algorithms = this.kexService.finishKeyExchange();
}
finally {
this.sessionSemaphore.release();
}
}
/**
* Attempts to read from the stream until the buffer is full.
* @returns True if the read succeeded, false if the stream was disposed.
*/
async read(buffer, cancellation) {
const stream = this.stream;
if (!stream)
return false;
let bytesRead = 0;
do {
let data;
try {
data = await stream.read(buffer.length - bytesRead, cancellation);
}
catch (e) {
if (!(e instanceof Error))
throw e;
if (stream.isDisposed)
return false;
stream.dispose();
this.stream = null;
this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.streamReadError, `Error reading from stream: ${e.message}`, e);
throw new errors_1.SshConnectionError('Error reading from stream: ' + e.message, transportMessages_1.SshDisconnectReason.connectionLost);
}
if (!data)
return false;
data.copy(buffer, bytesRead);
bytesRead += data.length;
} while (bytesRead < buffer.length);
return true;
}
/**
* Attempts to write data to the stream.
* @returns True if the write succeeded, false if the stream was disposed.
*/
async write(data, cancellation) {
const stream = this.stream;
if (!stream)
return false;
try {
await stream.write(data, cancellation);
}
catch (e) {
if (!(e instanceof Error))
throw e;
if (stream.isDisposed)
return false;
stream.dispose();
this.stream = null;
this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.streamWriteError, `Error writing to stream: ${e.message}`, e);
throw new errors_1.SshConnectionError('Error writing to stream: ' + e.message, transportMessages_1.SshDisconnectReason.connectionLost);
}
return true;
}
async considerReExchange(initial, cancellation) {
const kexService = this.kexService;
if (!kexService)
return;
let kexMessage = null;
let kexGuessMessage = null;
if (!kexService.exchanging &&
(initial || this.inboundFlow + this.outboundFlow > this.config.keyRotationThreshold)) {
[kexMessage, kexGuessMessage] = await kexService.startKeyExchange(initial);
}
if (kexMessage) {
await this.sendMessage(kexMessage, cancellation);
if (kexGuessMessage) {
await this.sendMessage(kexGuessMessage, cancellation);
}
}
}
async computeHmac(signer, payload, seq) {
const writer = new sshData_1.SshDataWriter(buffer_1.Buffer.alloc(4 + payload.length));
writer.writeUInt32(seq);
writer.write(payload);
const result = await signer.sign(writer.toBuffer());
return result;
}
async verifyHmac(verifier, payload, seq, mac) {
const writer = new sshData_1.SshDataWriter(buffer_1.Buffer.alloc(4 + payload.length));
writer.writeUInt32(seq);
writer.write(payload);
const result = await verifier.verify(writer.toBuffer(), mac);
return result;
}
async readAndVerifyHmac(verifier, data, macBuffer, cancellation) {
if (!(await this.read(macBuffer, cancellation))) {
return false;
}
const verified = await this.verifyHmac(verifier, data, this.inboundPacketSequence, macBuffer);
if (!verified) {
throw new errors_1.SshConnectionError('Invalid MAC', transportMessages_1.SshDisconnectReason.macError);
}
return true;
}
/**
* Attemps to write one message to the stream.
* @returns `true` if writing succeeded, `false` if the stream was disposed.
* @throws SshConnectionException if writing to the stream failed for any other reason.
*/
async sendMessage(message, cancellation) {
var _a;
const algorithms = this.algorithms;
const compression = algorithms === null || algorithms === void 0 ? void 0 : algorithms.compressor;
const encryption = algorithms === null || algorithms === void 0 ? void 0 : algorithms.cipher;
const hmac = algorithms === null || algorithms === void 0 ? void 0 : algorithms.messageSigner;
let result;
await this.sessionSemaphore.wait(cancellation);
try {
const blockSize = encryption ? Math.max(8, encryption.blockLength) : 8;
// Start by writing the uncompressed payload to the buffer at the correct offset.
const payloadOffset = SshProtocol.packetLengthSize + SshProtocol.paddingLengthSize;
this.sendWriter.position = payloadOffset;
message.write(this.sendWriter);
if (this.outgoingMessagesHaveReconnectInfo) {
// Write the sequence number of the last inbound packet processed.
this.sendWriter.writeUInt64(this.lastIncomingSequence);
if (this.outgoingMessagesHaveLatencyInfo) {
// Write the time (in microseconds, not ms) since last packet was received.
const timeSinceLastReceivedMessage = Math.min(4294967295, // max uint32
Math.round((this.metrics.time - this.lastIncomingTimestamp) * 1000));
this.sendWriter.writeUInt32(timeSinceLastReceivedMessage);
}
}
let payload = this.sendWriter.toBuffer().slice(payloadOffset);
if (compression != null) {
payload = compression.compress(payload);
}
// The packet length is not encrypted when in EtM or AEAD mode.
const isLengthEncrypted = !((hmac === null || hmac === void 0 ? void 0 : hmac.encryptThenMac) || (hmac === null || hmac === void 0 ? void 0 : hmac.authenticatedEncryption));
// http://tools.ietf.org/html/rfc4253
// 6. Binary Packet Protocol
// the total length of (packet_length || padding_length || payload || padding)
// is a multiple of the cipher block size or 8,
// padding length must between 4 and 255 bytes.
let paddingLength = blockSize -
(((isLengthEncrypted ? SshProtocol.packetLengthSize : 0) +
SshProtocol.paddingLengthSize +
payload.length) %
blockSize);
if (paddingLength < 4) {
paddingLength += blockSize;
}
const packetLength = SshProtocol.paddingLengthSize + payload.length + paddingLength;
this.sendWriter.position = 0;
this.sendWriter.writeUInt32(packetLength);
this.sendWriter.writeByte(paddingLength);
// The uncompressed payload was already written at the correct offset.
// When compression is enabled, rewrite the compressed payload.
if (compression != null) {
this.sendWriter.write(payload);
}
else {
this.sendWriter.position += payload.length;
}
this.sendWriter.writeRandom(paddingLength);
payload = this.sendWriter.toBuffer();
let mac = null;
if ((hmac === null || hmac === void 0 ? void 0 : hmac.encryptThenMac) && encryption) {
// In EtM mode, compute the MAC after encrypting. And don't encrypt the length.
const packetWithoutLength = payload.slice(SshProtocol.packetLengthSize, payload.length);
const encryptedPacket = await encryption.transform(packetWithoutLength);
encryptedPacket.copy(packetWithoutLength);
mac = await this.computeHmac(hmac, payload, this.outboundPacketSequence);
}
else if (hmac === null || hmac === void 0 ? void 0 : hmac.authenticatedEncryption) {
// With a GCM cipher, the packet length is not included in the plaintext.
const packetWithoutLength = payload.slice(SshProtocol.packetLengthSize, payload.length);
const encryptedPacket = await encryption.transform(packetWithoutLength);
encryptedPacket.copy(packetWithoutLength);
// The GCM tag was already generated during the transform call above;
// this just retrieves it.
mac = await hmac.sign(packetWithoutLength);
}
else {
if (hmac) {
mac = await this.computeHmac(hmac, payload, this.outboundPacketSequence);
}
if (encryption) {
payload = await encryption.transform(payload);
}
}
if (!(message instanceof connectionMessages_1.ChannelDataMessage)) {
this.trace(trace_1.TraceLevel.Verbose, trace_1.SshTraceEventIds.sendingMessage, `Sending #${this.outboundPacketSequence} ${message}`);
}
else if (this.traceChannelData) {
this.trace(trace_1.TraceLevel.Verbose, trace_1.SshTraceEventIds.sendingChannelData, `Sending #${this.outboundPacketSequence} ${message}`);
}
if (this.incomingMessagesHaveReconnectInfo) {
// Save sent messages in case they need to be re-sent after reconnect.
// They'll be discarded soon, after the other side acknowledges them.
const sequencedMessage = new SequencedMessage(this.outboundPacketSequence, message);
sequencedMessage.sentTime = this.metrics.time;
this.recentSentMessages.enqueue(sequencedMessage);
}
this.outboundPacketSequence++;
this.outboundFlow += packetLength;
if (mac) {
const packet = buffer_1.Buffer.concat([payload, mac], payload.length + mac.length);
result = await this.write(packet, cancellation);
}
else {
result = await this.write(payload, cancellation);
}
this.metrics.addMessageSent(SshProtocol.packetLengthSize + packetLength + ((_a = hmac === null || hmac === void 0 ? void 0 : hmac.digestLength) !== null && _a !== void 0 ? _a : 0));
}
finally {
this.sessionSemaphore.release();
}
await this.considerReExchange(false, cancellation);
return result;
}
/**
* Attemps to read one message from the stream.
* @returns The message, or `null` if the stream was disposed.
* @throws SshConnectionError if reading from the stream failed for any other reason.
*/
async receiveMessage(cancellation) {
var _a;
const algorithms = this.algorithms;
const encryption = algorithms === null || algorithms === void 0 ? void 0 : algorithms.decipher;
const hmac = algorithms === null || algorithms === void 0 ? void 0 : algorithms.messageVerifier;
const compression = algorithms === null || algorithms === void 0 ? void 0 : algorithms.decompressor;
// The packet length is not encrypted when in EtM or AEAD mode.
// So read only the length bytes first, separate from the remaining payload.
const isLengthEncrypted = !((hmac === null || hmac === void 0 ? void 0 : hmac.encryptThenMac) || (hmac === null || hmac === void 0 ? void 0 : hmac.authenticatedEncryption));
const firstBlockSize = !isLengthEncrypted
? SshProtocol.packetLengthSize
: encryption
? Math.max(8, encryption.blockLength)
: 8;
this.receiveWriter.position = firstBlockSize;
let firstBlock = this.receiveWriter.toBuffer();
if (!(await this.read(firstBlock, cancellation))) {
return null;
}
this.lastIncomingTimestamp = this.metrics.time;
// Decrypt the first block to get the packet length.
if (encryption && isLengthEncrypted) {
firstBlock = await encryption.transform(firstBlock);
this.receiveWriter.position = 0;
this.receiveWriter.write(firstBlock);
}
const receiveReader = new sshData_1.SshDataReader(firstBlock);
const packetLength = receiveReader.readUInt32();
if (packetLength > SshProtocol.maxPacketLength) {
throw new errors_1.SshConnectionError('Invalid packet length.', transportMessages_1.SshDisconnectReason.protocolError);
}
const packetBufferSize = SshProtocol.packetLengthSize + packetLength;
if (packetBufferSize > firstBlockSize) {
this.receiveWriter.skip(packetBufferSize - firstBlockSize);
}
if (hmac) {
// Ensure the receive buffer is large enough to also hold the mac without expanding.
this.receiveWriter.skip(hmac.digestLength);
}
const receiveBuffer = this.receiveWriter.toBuffer();
const packetBuffer = receiveBuffer.slice(0, packetBufferSize);
const macBuffer = receiveBuffer.slice(packetBufferSize);
let followingBlocks = packetBuffer.slice(firstBlockSize, packetBufferSize);
if (followingBlocks.length > 0) {
if (!(await this.read(followingBlocks, cancellation))) {
return null;
}
if (hmac === null || hmac === void 0 ? void 0 : hmac.encryptThenMac) {
// In EtM mode, read and verify the MAC before decrypting.
///const packetWithoutLength = packet.slice(SshProtocol.packetLengthSize);
if (!(await this.readAndVerifyHmac(hmac, packetBuffer, macBuffer, cancellation))) {
return null;
}
}
if (encryption) {
if (hmac === null || hmac === void 0 ? void 0 : hmac.authenticatedEncryption) {
// With a GCM cipher, the MAC is required for decryption.
if (!(await this.read(macBuffer, cancellation))) {
return null;
}
// This doesn't actually verify anything yet (hence the return value is not checked);
// it sets the tag to be used for verification in the following transform call.
await hmac.verify(followingBlocks, macBuffer);
}
try {
followingBlocks = await encryption.transform(followingBlocks);
}
catch (e) {
if (hmac === null || hmac === void 0 ? void 0 : hmac.authenticatedEncryption) {
// GCM decryption failed to verify data + tag.
throw new errors_1.SshConnectionError('Invalid MAC', transportMessages_1.SshDisconnectReason.macError);
}
else {
throw e;
}
}
this.receiveWriter.position = firstBlockSize;
this.receiveWriter.write(followingBlocks);
}
}
if (hmac && !hmac.encryptThenMac && !hmac.authenticatedEncryption) {
if (!(await this.readAndVerifyHmac(hmac, packetBuffer, macBuffer, cancellation))) {
return null;
}
}
const paddingLength = packetBuffer[SshProtocol.packetLengthSize];
let payload = packetBuffer.slice(SshProtocol.packetLengthSize + SshProtocol.paddingLengthSize, SshProtocol.packetLengthSize + (packetLength - paddingLength));
if (compression) {
payload = compression.decompress(payload);
}
if (this.incomingMessagesHaveReconnectInfo) {
// Read the extension info from the end of the payload.
let lastSequenceSeenByRemote;
let remoteTimeSinceLastReceived;
if (this.incomingMessagesHaveLatencyInfo) {
const reader = new sshData_1.SshDataReader(payload.slice(payload.length - 12, payload.length));
lastSequenceSeenByRemote = reader.readUInt64();
remoteTimeSinceLastReceived = reader.readUInt32() / 1000; // microseconds to ms
payload = payload.slice(0, payload.length - 12);
}
else {
const reader = new sshData_1.SshDataReader(payload.slice(payload.length - 8, payload.length));
lastSequenceSeenByRemote = reader.readUInt64();
remoteTimeSinceLastReceived = 0;
payload = payload.slice(0, payload.length - 8);
}
// Discard any recently sent messages that were acknowledged.
while (this.recentSentMessages.size > 0) {
const oldestSequenceMessage = this.recentSentMessages.peek();
if (oldestSequenceMessage.sequence > lastSequenceSeenByRemote) {
break;
}
if (this.stream &&
this.incomingMessagesHaveLatencyInfo &&
oldestSequenceMessage.sequence === lastSequenceSeenByRemote) {
// Compute the time since the message with the last-seen sequence was sent.
// Subtract the time between when the remote side received the message with the
// last-seen sequence and sent the current message.
const timeSinceSent = this.lastIncomingTimestamp - oldestSequenceMessage.sentTime;
const roundTripLatency = timeSinceSent - remoteTimeSinceLastReceived;
this.metrics.updateLatency(roundTripLatency, this.trace);
}
this.recentSentMessages.dequeue();
}
}
const messageType = payload[0];
let message = sshMessage_1.SshMessage.create(this.config, messageType, this.messageContext, payload);
if (!message) {
const unimplementedMessage = new transportMessages_1.UnimplementedMessage();
unimplementedMessage.sequenceNumber = this.inboundPacketSequence;
unimplementedMessage.unimplementedMessageType = messageType;
message = unimplementedMessage;
}
if (!(message instanceof connectionMessages_1.ChannelDataMessage)) {
this.trace(trace_1.TraceLevel.Verbose, trace_1.SshTraceEventIds.receivingMessage, `Receiving #${this.inboundPacketSequence} ${message}`);
}
else if (this.traceChannelData) {
this.trace(trace_1.TraceLevel.Verbose, trace_1.SshTraceEventIds.receivingChannelData, `Receiving #${this.inboundPacketSequence} ${message}`);
}
await this.sessionSemaphore.wait(cancellation);
this.inboundPacketSequence++;
this.inboundFlow += packetLength;
this.sessionSemaphore.release();
this.metrics.addMessageReceived(SshProtocol.packetLengthSize + packetLength + ((_a = hmac === null || hmac === void 0 ? void 0 : hmac.digestLength) !== null && _a !== void 0 ? _a : 0));
await this.considerReExchange(false, cancellation);
return message;
}
dispose() {
try {
if (this.stream)
this.stream.close().catch((e) => {
this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.streamCloseError, `Error closing stream: ${e.message}`, e);
});
}
catch (e) {
if (!(e instanceof Error))
throw e;
this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.streamCloseError, `Error closing stream: ${e.message}`, e);
}
this.stream = null;
this.metrics.updateLatency(0);
if (this.algorithms)
this.algorithms.dispose();
}
}
exports.SshProtocol = SshProtocol;
SshProtocol.maxPacketLength = 1024 * 1024; // 1 MB
SshProtocol.packetLengthSize = 4;
SshProtocol.paddingLengthSize = 1;
//# sourceMappingURL=sshProtocol.js.map