@microsoft/dev-tunnels-ssh
Version:
SSH library for Dev Tunnels
309 lines • 16.5 kB
JavaScript
"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