UNPKG

@microsoft/dev-tunnels-ssh

Version:
309 lines 16.5 kB
"use strict"; // // Copyright (c) Microsoft Corporation. All rights reserved. // Object.defineProperty(exports, "__esModule", { value: true }); exports.SshClientSession = void 0; const sshSession_1 = require("./sshSession"); const transportMessages_1 = require("./messages/transportMessages"); const sshAuthenticatingEventArgs_1 = require("./events/sshAuthenticatingEventArgs"); const promiseCompletionSource_1 = require("./util/promiseCompletionSource"); const cancellation_1 = require("./util/cancellation"); const sshSessionConfiguration_1 = require("./sshSessionConfiguration"); const authenticationService_1 = require("./services/authenticationService"); const connectionService_1 = require("./services/connectionService"); const errors_1 = require("./errors"); const trace_1 = require("./trace"); /** * The client side of an SSH session. Extends the base `SshSession` class to * support client authentication. */ class SshClientSession extends sshSession_1.SshSession { constructor(config) { super(config, true); this.serviceRequests = new Map(); this.clientAuthCompletion = null; } /** * Attempts to authenticate both the server and client. * * This method must be called only after encrypting the session. It is equivalent * to calling both `authenticateServer()` and `authenticateClient()` and waiting on * both results. * * @returns `true` if authentication succeeded, `false` if it failed. */ async authenticate(clientCredentials, cancellation) { const serverAuthenticated = await this.authenticateServer(cancellation); if (!serverAuthenticated) { return false; } const clientAuthenticated = await this.authenticateClient(clientCredentials, cancellation); if (!clientAuthenticated) { return false; } return true; } /** * Triggers server authentication by invoking the `authenticating` event with * the verified server host key. * * This method must be called only after encrypting the session. It does not wait for any * further message exchange with the server, since the server host key would have already * been obtained during the key-exchange. * * @returns `true` if authentication succeeded, `false` if it failed. */ async authenticateServer(cancellation) { if (!(this.kexService && this.kexService.hostKey)) { throw new Error('Encrypt the session before authenticating.'); } try { // Raise an Authenticating event that allows handlers to do verification // of the host key and return a principal for the server. this.principal = await this.raiseAuthenticatingEvent(new sshAuthenticatingEventArgs_1.SshAuthenticatingEventArgs(sshAuthenticatingEventArgs_1.SshAuthenticationType.serverPublicKey, { publicKey: this.kexService.hostKey, }, cancellation)); } catch (e) { if (!(e instanceof Error)) throw e; this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.authenticationError, `Error while authenticating server: ${e.message}`, e); throw e; } if (!this.principal) { await this.close(transportMessages_1.SshDisconnectReason.hostKeyNotVerifiable, 'Server authentication failed.'); this.trace(trace_1.TraceLevel.Warning, trace_1.SshTraceEventIds.serverAuthenticationFailed, `${this} server authentication failed.`); return false; } this.trace(trace_1.TraceLevel.Info, trace_1.SshTraceEventIds.sessionAuthenticated, `${this} server authenticated.`); return true; } /* @internal */ authenticateClient(credentials, callbackOrCancellation, cancellation) { if (!credentials) { throw new TypeError('A credentials object is required.'); } if (typeof callbackOrCancellation === 'function') { return this.authenticateClientWithCompletion(credentials, callbackOrCancellation, cancellation); } else { return new Promise((resolve, reject) => this.authenticateClientWithCompletion(credentials, (err, result) => { if (err) reject(err); else resolve(result); }, callbackOrCancellation)); } } async authenticateClientWithCompletion(credentials, callback, cancellation) { this.clientAuthCompletion = new promiseCompletionSource_1.PromiseCompletionSource(); this.clientAuthCompletion.promise.then((result) => callback(undefined, result), (err) => callback(err)); if (cancellation) { if (cancellation.isCancellationRequested) throw new cancellation_1.CancellationError(); cancellation.onCancellationRequested((e) => { if (this.clientAuthCompletion) { this.clientAuthCompletion.reject(new cancellation_1.CancellationError()); } }); } let authService = this.getService(authenticationService_1.AuthenticationService); if (!authService) { const serviceRequestMessage = new transportMessages_1.ServiceRequestMessage(); serviceRequestMessage.serviceName = authenticationService_1.AuthenticationService.serviceName; await this.sendMessage(serviceRequestMessage, cancellation); // Assume the service request is accepted, without waiting for an accept message. // (If not, the following auth requests will fail anyway.) authService = this.activateService(authenticationService_1.AuthenticationService); } await authService.authenticateClient(credentials, cancellation); } /* @internal */ onAuthenticationComplete(success) { if (success) { this.trace(trace_1.TraceLevel.Info, trace_1.SshTraceEventIds.sessionAuthenticated, `${this} client authenticated.`); } else { this.trace(trace_1.TraceLevel.Warning, trace_1.SshTraceEventIds.clientAuthenticationFailed, `${this} client authentication failed.`); } if (this.clientAuthCompletion) { this.clientAuthCompletion.resolve(success); this.clientAuthCompletion = null; } } /** * Sends a request for a service and waits for a response. * * @param serviceName Name of the service to be requested. * @param cancellation Optional cancellation token. * @returns A promise that resolves when the service request has been accepted. * * If the server does not accept the service request, it will disconnect the session. */ async requestService(serviceName, cancellation) { let sendRequest = false; let completion = this.serviceRequests.get(serviceName); if (!completion) { completion = new promiseCompletionSource_1.PromiseCompletionSource(); this.serviceRequests.set(serviceName, completion); sendRequest = true; } if (sendRequest) { const requestMessage = new transportMessages_1.ServiceRequestMessage(); requestMessage.serviceName = serviceName; await this.sendMessage(requestMessage, cancellation); } await completion.promise; } /* @internal */ async handleServiceAcceptMessage(message, cancellation) { const completion = this.serviceRequests.get(message.serviceName); completion === null || completion === void 0 ? void 0 : completion.resolve(true); } async openChannel(channelTypeOrOpenMessageOrCancellation, initialRequestOrCancellation, cancellation) { if (!this.connectionService) { // Authentication must have been skipped, meaning there was no // connection service request sent yet. Send it now, and assume // it is accepted without waiting for a response. const serviceRequestMessage = new transportMessages_1.ServiceRequestMessage(); serviceRequestMessage.serviceName = connectionService_1.ConnectionService.serviceName; await this.sendMessage(serviceRequestMessage, cancellation); } return await super.openChannel(channelTypeOrOpenMessageOrCancellation, initialRequestOrCancellation, cancellation); } /* @internal */ handleDisconnected() { if (this.reconnecting) { this.reconnecting = false; return false; } return super.handleDisconnected(); } /** * Call instead of `connect()` to reconnect to a prior session instead of connecting * a new session. * @param stream A new stream that has just (re-) connected to the server. * @param cancellation Optional cancellation token. * @returns True if reconnect succeeded, false if the server declined the reconnect * request or reconnect session validation failed. In the case of a false return value, * retrying is unlikely to succeed. * @throws {SshConnectionError} There was a problem connecting to or communicating with * the server; retrying may still succeed if connectivity is restored. * @throws {SshReconnectError} Reconnect failed for some reason other than a communication * issue: see the `failureReason` property of the error. Retrying is unlikely to succeed, * unless the specific error condition can be addressed. */ async reconnect(stream, cancellation) { this.trace(trace_1.TraceLevel.Verbose, trace_1.SshTraceEventIds.clientSessionReconnecting, 'Attempting to reconnect...'); if (this.isClosed) { throw new errors_1.ObjectDisposedError(this); } else if (this.isConnected) { throw new Error('Already connected.'); } if (!this.protocol) { throw new Error('The session was never previously connected.'); } if (this.reconnecting) { throw new Error('Already reconnecting.'); } this.reconnecting = true; try { await this.reconnectInternal(stream, cancellation); } finally { this.reconnecting = false; } } async reconnectInternal(stream, cancellation) { var _a, _b, _c, _d, _e, _f; const previousSessionId = this.sessionId; const previousProtocolInstance = this.protocol; const previousHostKey = (_a = this.kexService) === null || _a === void 0 ? void 0 : _a.hostKey; if (!previousSessionId || !previousProtocolInstance || !this.kexService || !previousHostKey || !((_b = previousProtocolInstance.extensions) === null || _b === void 0 ? void 0 : _b.has(sshSessionConfiguration_1.SshProtocolExtensionNames.sessionReconnect))) { throw new Error('Reconnect was not enabled for this session.'); } let newSessionId; try { // Reconnecting will temporarily create a new session ID. this.sessionId = null; await this.connect(stream, cancellation); if (!this.sessionId || !this.algorithms || !this.algorithms.signer) { throw new Error('Session is not encrypted.'); } // Ensure the client is not reconnecting to a different server. const newHostKey = this.kexService.hostKey; const newHostPublicKey = !newHostKey ? null : await newHostKey.getPublicKeyBytes(); const previousHostPublicKey = await previousHostKey.getPublicKeyBytes(); if (!newHostPublicKey || !previousHostPublicKey || !newHostPublicKey.equals(previousHostPublicKey)) { const message = 'The server host key is different.'; this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.clientSessionReconnectFailed, `Reconnection failed: ${message}`); throw new errors_1.SshReconnectError(message, transportMessages_1.SshReconnectFailureReason.differentServerHostKey); } newSessionId = this.sessionId; } catch (e) { // Restore the previous protocol instance so reconnect may be attempted again. this.protocol = previousProtocolInstance; super.handleDisconnected(); throw e; } finally { // Restore the previous session ID and host key for the reconnected session. this.sessionId = previousSessionId; this.kexService.hostKey = previousHostKey; } const reconnectToken = await this.createReconnectToken(previousSessionId, newSessionId); const reconnectRequest = new transportMessages_1.SessionReconnectRequestMessage(); reconnectRequest.requestType = "session-reconnect@microsoft.com" /* ExtensionRequestTypes.sessionReconnect */; reconnectRequest.clientReconnectToken = reconnectToken; reconnectRequest.lastReceivedSequenceNumber = previousProtocolInstance.lastIncomingSequence; reconnectRequest.wantReply = true; const response = await this.requestResponse(reconnectRequest, transportMessages_1.SessionReconnectResponseMessage, transportMessages_1.SessionReconnectFailureMessage, cancellation); if (response instanceof transportMessages_1.SessionReconnectFailureMessage) { const reason = (_c = response.reasonCode) !== null && _c !== void 0 ? _c : transportMessages_1.SshReconnectFailureReason.unknownServerFailure; const message = (_d = response.description) !== null && _d !== void 0 ? _d : 'The server rejected the reconnect request.'; this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.clientSessionReconnectFailed, `Reconnection failed: ${message}`); // Restore the previous protocol instance so reconnect may be attempted again. this.protocol = previousProtocolInstance; throw new errors_1.SshReconnectError(message, reason); } if (!this.verifyReconnectToken(previousSessionId, newSessionId, (_e = response.serverReconnectToken) !== null && _e !== void 0 ? _e : Buffer.alloc(0))) { const message = 'The reconnect token provided by the server was invalid.'; this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.clientSessionReconnectFailed, `Reconnection failed: ${message}`); throw new errors_1.SshReconnectError(message, transportMessages_1.SshReconnectFailureReason.invalidServerReconnectToken); } this.trace(trace_1.TraceLevel.Info, trace_1.SshTraceEventIds.clientSessionReconnecting, 'Reconnect request was accepted by the server.'); // Re-send lost messages. const messagesToResend = previousProtocolInstance.getSentMessages(((_f = response.lastReceivedSequenceNumber) !== null && _f !== void 0 ? _f : 0) + 1); if (!messagesToResend) { const message = 'Client is unable to re-send messages requested by the server.'; this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.clientSessionReconnectFailed, `Reconnection failed: ${message}`); throw new errors_1.SshReconnectError(message, transportMessages_1.SshReconnectFailureReason.clientDroppedMessages); } let count = 0; for (const message of messagesToResend) { await this.sendMessage(message, cancellation); count++; } // Now the session is fully reconnected! previousProtocolInstance.dispose(); this.metrics.addReconnection(); this.trace(trace_1.TraceLevel.Info, trace_1.SshTraceEventIds.clientSessionReconnecting, `${this} reconnected. Re-sent ${count} dropped messages.`); } dispose() { if (this.clientAuthCompletion) { this.clientAuthCompletion.reject(new errors_1.ObjectDisposedError(this)); } super.dispose(); } } exports.SshClientSession = SshClientSession; //# sourceMappingURL=sshClientSession.js.map