// Sshwifty - A Web SSH client // // Copyright (C) 2019 Rui NI // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import * as address from "./address.js"; import * as command from "./commands.js"; import * as common from "./common.js"; import * as event from "./events.js"; import * as reader from "../stream/reader.js"; import * as stream from "../stream/stream.js"; import * as controls from "./controls.js"; import * as header from "../stream/header.js"; import * as history from "./history.js"; import * as strings from "./string.js"; import Exception from "./exception.js"; const AUTHMETHOD_NONE = 0x00; const AUTHMETHOD_PASSPHRASE = 0x01; const AUTHMETHOD_PRIVATE_KEY = 0x02; const COMMAND_ID = 0x01; const MAX_USERNAME_LEN = 64; const MAX_PASSWORD_LEN = 4096; const DEFAULT_PORT = 22; const SERVER_REMOTE_STDOUT = 0x00; const SERVER_REMOTE_STDERR = 0x01; const SERVER_CONNECT_FAILED = 0x02; const SERVER_CONNECTED = 0x03; const SERVER_CONNECT_REQUEST_FINGERPRINT = 0x04; const SERVER_CONNECT_REQUEST_CREDENTIAL = 0x05; const CLIENT_DATA_STDIN = 0x00; const CLIENT_DATA_RESIZE = 0x01; const CLIENT_CONNECT_RESPOND_FINGERPRINT = 0x02; const CLIENT_CONNECT_RESPOND_CREDENTIAL = 0x03; const SERVER_REQUEST_ERROR_BAD_USERNAME = 0x01; const SERVER_REQUEST_ERROR_BAD_ADDRESS = 0x02; const SERVER_REQUEST_ERROR_BAD_AUTHMETHOD = 0x03; const FingerprintPromptVerifyPassed = 0x00; const FingerprintPromptVerifyNoRecord = 0x01; const FingerprintPromptVerifyMismatch = 0x02; class SSH { /** * constructor * * @param {stream.Sender} sd Stream sender * @param {object} config configuration * @param {object} callbacks Event callbacks * */ constructor(sd, config, callbacks) { this.sender = sd; this.config = config; this.connected = false; this.events = new event.Events( [ "initialization.failed", "initialized", "connect.failed", "connect.succeed", "connect.fingerprint", "connect.credential", "@stdout", "@stderr", "close", "@completed" ], callbacks ); } /** * Send intial request * * @param {stream.InitialSender} initialSender Initial stream request sender * */ run(initialSender) { let user = new strings.String(this.config.user), userBuf = user.buffer(), addr = new address.Address( this.config.host.type, this.config.host.address, this.config.host.port ), addrBuf = addr.buffer(), authMethod = new Uint8Array([this.config.auth]); let data = new Uint8Array(userBuf.length + addrBuf.length + 1); data.set(userBuf, 0); data.set(addrBuf, userBuf.length); data.set(authMethod, userBuf.length + addrBuf.length); initialSender.send(data); } /** * Receive the initial stream request * * @param {header.InitialStream} streamInitialHeader Server respond on the * initial stream request * */ initialize(streamInitialHeader) { if (!streamInitialHeader.success()) { this.events.fire("initialization.failed", streamInitialHeader); return; } this.events.fire("initialized", streamInitialHeader); } /** * Tick the command * * @param {header.Stream} streamHeader Stream data header * @param {reader.Limited} rd Data reader * * @returns {any} The result of the ticking * * @throws {Exception} When the stream header type is unknown * */ tick(streamHeader, rd) { switch (streamHeader.marker()) { case SERVER_CONNECTED: if (!this.connected) { this.connected = true; return this.events.fire("connect.succeed", rd, this); } break; case SERVER_CONNECT_FAILED: if (!this.connected) { return this.events.fire("connect.failed", rd); } break; case SERVER_CONNECT_REQUEST_FINGERPRINT: if (!this.connected) { return this.events.fire("connect.fingerprint", rd, this.sender); } break; case SERVER_CONNECT_REQUEST_CREDENTIAL: if (!this.connected) { return this.events.fire("connect.credential", rd, this.sender); } break; case SERVER_REMOTE_STDOUT: if (this.connected) { return this.events.fire("stdout", rd); } break; case SERVER_REMOTE_STDERR: if (this.connected) { return this.events.fire("stderr", rd); } break; } throw new Exception("Unknown stream header marker"); } /** * Send close signal to remote * */ async sendClose() { return await this.sender.close(); } /** * Send data to remote * * @param {Uint8Array} data * */ async sendData(data) { return this.sender.sendData(CLIENT_DATA_STDIN, data); } /** * Send resize request * * @param {number} rows * @param {number} cols * */ async sendResize(rows, cols) { let data = new DataView(new ArrayBuffer(4)); data.setUint16(0, rows); data.setUint16(2, cols); return this.sender.send(CLIENT_DATA_RESIZE, new Uint8Array(data.buffer)); } /** * Close the command * */ async close() { await this.sendClose(); return this.events.fire("close"); } /** * Tear down the command completely * */ completed() { return this.events.fire("completed"); } } const initialFieldDef = { User: { name: "User", description: "", type: "text", value: "", example: "guest", verify(d) { if (d.length <= 0) { throw new Error("Username must be specified"); } if (d.length > MAX_USERNAME_LEN) { throw new Error( "Username must not longer than " + MAX_USERNAME_LEN + " bytes" ); } return "We'll login as user \"" + d + '"'; } }, Host: { name: "Host", description: "", type: "text", value: "", example: "ssh.vaguly.com:22", verify(d) { if (d.length <= 0) { throw new Error("Hostname must be specified"); } let addr = common.splitHostPort(d, DEFAULT_PORT); if (addr.addr.length <= 0) { throw new Error("Cannot be empty"); } if (addr.addr.length > address.MAX_ADDR_LEN) { throw new Error( "Can no longer than " + address.MAX_ADDR_LEN + " bytes" ); } if (addr.port <= 0) { throw new Error("Port must be specified"); } return "Look like " + addr.type + " address"; } }, Notice: { name: "Notice", description: "", type: "textdata", value: "SSH session is handled by the backend. Traffic will be decrypted " + "on the backend server and then transmit back to your client.", example: "", verify(d) { return ""; } }, Password: { name: "Password", description: "", type: "password", value: "", example: "----------", verify(d) { if (d.length <= 0) { throw new Error("Password must be specified"); } if (d.length > MAX_PASSWORD_LEN) { throw new Error( "It's too long, make it shorter than " + MAX_PASSWORD_LEN + " bytes" ); } return "We'll login with this password"; } }, "Private Key": { name: "Private Key", description: 'Like the one inside ' + "~/.ssh/id_rsa, can't be encrypted

" + 'To decrypt the Private Key, use command: ssh-keygen -f /path/to/private_key -p
' + "
" + "It is strongly recommanded to use one Private Key per SSH server if " + "the Private Key will be submitted to Sshwifty. To generate a new SSH " + 'key pair, use command ' + "ssh-keygen -o -f /path/to/my_server_key and then deploy the " + 'generated ' + "/path/to/my_server_key.pub file onto the target SSH server", type: "textfile", value: "", example: "", verify(d) { if (d.length <= 0) { throw new Error("Private Key must be specified"); } if (d.length > MAX_PASSWORD_LEN) { throw new Error( "It's too long, make it shorter than " + MAX_PASSWORD_LEN + " bytes" ); } const lines = d.trim().split("\n"); let firstLineReaded = false; for (let i in lines) { if (!firstLineReaded) { if (lines[i].indexOf("-") === 0) { firstLineReaded = true; if (lines[i].indexOf("RSA") <= 0) { break; } } continue; } if (lines[i].indexOf("Proc-Type: 4,ENCRYPTED") === 0) { throw new Error("Cannot use encrypted Private Key file"); } if (lines[i].indexOf(":") > 0) { continue; } if (lines[i].indexOf("MII") < 0) { throw new Error("Cannot use encrypted Private Key file"); } break; } return "We'll login with this Private Key"; } }, Authentication: { name: "Authentication", description: "Please make sure the authentication method that you selected is " + "supported by the server, otherwise it will be ignored and likely " + "cause the login to fail", type: "radio", value: "", example: "Password,Private Key,None", verify(d) { switch (d) { case "Password": case "Private Key": case "None": return ""; default: throw new Error("Authentication method must be specified"); } } }, Fingerprint: { name: "Fingerprint", description: "Please carefully verify the fingerprint. DO NOT continue " + "if the fingerprint is unknown to you, otherwise you maybe " + "giving your own secrets to an imposter", type: "textdata", value: "", example: "", verify(d) { return ""; } } }; /** * Return auth method from given string * * @param {string} d string data * * @returns {number} Auth method * * @throws {Exception} When auth method is invalid * */ function getAuthMethodFromStr(d) { switch (d) { case "None": return AUTHMETHOD_NONE; case "Password": return AUTHMETHOD_PASSPHRASE; case "Private Key": return AUTHMETHOD_PRIVATE_KEY; default: throw new Exception("Unknown Auth method"); } } class Wizard { /** * constructor * * @param {command.Info} info * @param {object} config * @param {object} session * @param {streams.Streams} streams * @param {subscribe.Subscribe} subs * @param {controls.Controls} controls * @param {history.History} history * */ constructor(info, config, session, streams, subs, controls, history) { this.info = info; this.hasStarted = false; this.streams = streams; this.config = config; this.session = session ? session : { credential: "" }; this.step = subs; this.controls = controls; this.history = history; this.step.resolve(this.stepInitialPrompt()); } started() { return this.hasStarted; } close() { this.step.resolve( this.stepErrorDone( "Action cancelled", "Action has been cancelled without reach any success" ) ); } stepErrorDone(title, message) { return command.done(false, null, title, message); } stepSuccessfulDone(data) { return command.done( true, data, "Success!", "We have connected to the remote" ); } stepWaitForAcceptWait() { return command.wait( "Requesting", "Waiting for request to be accepted by the backend" ); } stepWaitForEstablishWait(host) { return command.wait( "Connecting to " + host, "Establishing connection with the remote host, may take a while" ); } stepContinueWaitForEstablishWait() { return command.wait( "Connecting", "Establishing connection with the remote host, may take a while" ); } /** * * @param {stream.Sender} sender * @param {object} configInput * @param {object} sessionData * */ buildCommand(sender, configInput, sessionData) { let self = this; let config = { user: common.strToUint8Array(configInput.user), auth: getAuthMethodFromStr(configInput.authentication), credential: sessionData.credential, host: address.parseHostPort(configInput.host, DEFAULT_PORT), fingerprint: configInput.fingerprint }; return new SSH(sender, config, { "initialization.failed"(hd) { switch (hd.data()) { case SERVER_REQUEST_ERROR_BAD_USERNAME: self.step.resolve( self.stepErrorDone("Request failed", "Invalid username") ); return; case SERVER_REQUEST_ERROR_BAD_ADDRESS: self.step.resolve( self.stepErrorDone("Request failed", "Invalid address") ); return; case SERVER_REQUEST_ERROR_BAD_AUTHMETHOD: self.step.resolve( self.stepErrorDone("Request failed", "Invalid authication method") ); return; } self.step.resolve( self.stepErrorDone("Request failed", "Unknown error: " + hd.data()) ); }, initialized(hd) { self.step.resolve(self.stepWaitForEstablishWait(configInput.host)); }, async "connect.failed"(rd) { let d = new TextDecoder("utf-8").decode( await reader.readCompletely(rd) ); self.step.resolve(self.stepErrorDone("Connection failed", d)); }, "connect.succeed"(rd, commandHandler) { self.connectionSucceed = true; self.step.resolve( self.stepSuccessfulDone( new command.Result( configInput.user + "@" + configInput.host, self.info, self.controls.get("SSH", { send(data) { return commandHandler.sendData(data); }, close() { return commandHandler.sendClose(); }, resize(rows, cols) { return commandHandler.sendResize(rows, cols); }, events: commandHandler.events }) ) ) ); self.history.save( self.info.name() + ":" + configInput.user + "@" + configInput.host, configInput.user + "@" + configInput.host, new Date(), self.info, configInput, sessionData ); }, async "connect.fingerprint"(rd, sd) { self.step.resolve( await self.stepFingerprintPrompt( rd, sd, v => { if (!configInput.fingerprint) { return FingerprintPromptVerifyNoRecord; } if (configInput.fingerprint === v) { return FingerprintPromptVerifyPassed; } return FingerprintPromptVerifyMismatch; }, newFingerprint => { configInput.fingerprint = newFingerprint; } ) ); }, async "connect.credential"(rd, sd) { self.step.resolve( self.stepCredentialPrompt(rd, sd, config, newCredential => { sessionData.credential = newCredential; }) ); }, "@stdout"(rd) {}, "@stderr"(rd) {}, close() {}, "@completed"() { self.step.resolve( self.stepErrorDone( "Operation has failed", "Connection has been cancelled" ) ); } }); } stepInitialPrompt() { let self = this; if (this.config) { self.hasStarted = true; self.streams.request(COMMAND_ID, sd => { return self.buildCommand(sd, this.config, this.session); }); return self.stepWaitForAcceptWait(); } return command.prompt( "SSH", "Secure Shell Host", "Connect", r => { self.hasStarted = true; self.streams.request(COMMAND_ID, sd => { return self.buildCommand( sd, { user: r.user, authentication: r.authentication, host: r.host, fingerprint: "" }, this.session ); }); self.step.resolve(self.stepWaitForAcceptWait()); }, () => {}, command.fields(initialFieldDef, [ { name: "User" }, { name: "Host" }, { name: "Authentication" }, { name: "Notice" } ]) ); } async stepFingerprintPrompt(rd, sd, verify, newFingerprint) { let self = this, fingerprintData = new TextDecoder("utf-8").decode( await reader.readCompletely(rd) ), fingerprintChanged = false; switch (verify(fingerprintData)) { case FingerprintPromptVerifyPassed: sd.send(CLIENT_CONNECT_RESPOND_FINGERPRINT, new Uint8Array([0])); return this.stepContinueWaitForEstablishWait(); case FingerprintPromptVerifyMismatch: fingerprintChanged = true; } return command.prompt( !fingerprintChanged ? "Do you recognize this server?" : "Danger! Server fingerprint has changed!", !fingerprintChanged ? "Verify server fingerprint displayed below" : "It's very unusual. Please verify the new server fingerprint below", !fingerprintChanged ? "Yes, I do" : "I'm aware of the change", r => { newFingerprint(fingerprintData); sd.send(CLIENT_CONNECT_RESPOND_FINGERPRINT, new Uint8Array([0])); self.step.resolve(self.stepContinueWaitForEstablishWait()); }, () => { sd.send(CLIENT_CONNECT_RESPOND_FINGERPRINT, new Uint8Array([1])); self.step.resolve( command.wait("Rejecting", "Sending rejection to the backend") ); }, command.fields(initialFieldDef, [ { name: "Fingerprint", value: fingerprintData } ]) ); } async stepCredentialPrompt(rd, sd, config, newCredential) { let self = this, fields = []; if (config.credential.length > 0) { sd.send( CLIENT_CONNECT_RESPOND_CREDENTIAL, new TextEncoder().encode(config.credential) ); return this.stepContinueWaitForEstablishWait(); } switch (config.auth) { case AUTHMETHOD_PASSPHRASE: fields = [{ name: "Password" }]; break; case AUTHMETHOD_PRIVATE_KEY: fields = [{ name: "Private Key" }]; break; default: throw new Exception( "Prompt is not support by auth method: " + config.auth ); } return command.prompt( "Provide credential", "Please input your credential", "Login", r => { let vv = r[fields[0].name.toLowerCase()]; sd.send( CLIENT_CONNECT_RESPOND_CREDENTIAL, new TextEncoder().encode(vv) ); newCredential(vv); self.step.resolve(self.stepContinueWaitForEstablishWait()); }, () => { sd.close(); self.step.resolve( command.wait( "Cancelling login", "Cancelling login request, please wait" ) ); }, command.fields(initialFieldDef, fields) ); } } export class Command { constructor() {} id() { return COMMAND_ID; } name() { return "SSH"; } description() { return "Secure Shell Host"; } color() { return "#3c8"; } builder(info, config, session, streams, subs, controls, history) { return new Wizard(info, config, session, streams, subs, controls, history); } launch(info, launcher, streams, subs, controls, history) { let matchResult = launcher.match(new RegExp("^(.*)\\@(.*)\\|(.*)$")); if (!matchResult || matchResult.length !== 4) { throw new Exception('Given launcher "' + launcher + '" was malformed'); } let user = matchResult[1], host = matchResult[2], auth = matchResult[3]; try { initialFieldDef["User"].verify(user); initialFieldDef["Host"].verify(host); initialFieldDef["Authentication"].verify(auth); } catch (e) { throw new Exception( 'Given launcher "' + launcher + '" was malformed ' + e ); } return this.builder( info, { user: user, host: host, authentication: auth }, null, streams, subs, controls, history ); } launcher(config) { return config.user + "@" + config.host + "|" + config.authentication; } }