UNPKG

@microsoft/dev-tunnels-ssh

Version:
527 lines 26.7 kB
"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