diff --git a/CONFIG.md b/CONFIG.md index 299c45a..68eea3e 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -84,7 +84,7 @@ Renamed and expanded options: ## Detailed Changes ### 1. Authentication Options -- Added support for SSH private key authentication via `user.privateKey` +- Added support for SSH private key authentication via `user.privateKey` and passphrase encrypted private keys via `user.passphrase` - Removed `user.overridebasic` option - Added keyboard-interactive authentication controls @@ -128,7 +128,8 @@ These settings are now managed client-side. "user": { "name": null, "password": null, - "privateKey": null + "privateKey": null, + "passphrase": null }, "ssh": { "host": null, @@ -138,7 +139,39 @@ These settings are now managed client-side. "keepaliveInterval": 120000, "keepaliveCountMax": 10, "algorithms": { - // ... algorithm configurations ... + "cipher": [ + "aes128-ctr", + "aes192-ctr", + "aes256-ctr", + "aes128-gcm", + "aes128-gcm@openssh.com", + "aes256-gcm", + "aes256-gcm@openssh.com", + "aes256-cbc" + ], + "compress": [ + "none", + "zlib@openssh.com", + "zlib" + ], + "hmac": [ + "hmac-sha2-256", + "hmac-sha2-512", + "hmac-sha1" + ], + "kex": [ + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp521", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group14-sha1" + ], + "serverHostKey": [ + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "ssh-rsa" + ] } }, "options": { diff --git a/ChangeLog.md b/ChangeLog.md index cae9fda..c957620 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -2,6 +2,46 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. + +## [0.2.24](https://github.com/billchurch/WebSSH2/compare/v0.2.23...v0.2.24) (2024-12-04) + + +### Bug Fixes + +* config.json.sample had `disableInteractiveAuth` set to `true`, changed to `false` ([0c5de9f](https://github.com/billchurch/WebSSH2/commit/0c5de9f)) + + + + +## [0.2.23](https://github.com/billchurch/WebSSH2/compare/v0.2.20...v0.2.23) (2024-12-04) + + +### Bug Fixes + +* fixes document: config file moved from /usr/src to /usr/src/app [#372](https://github.com/billchurch/WebSSH2/issues/372) ([bc2d018](https://github.com/billchurch/WebSSH2/commit/bc2d018)) +* bug: support /ssh/host without a hostname [#373](https://github.com/billchurch/WebSSH2/issues/373) ([8c55c83](https://github.com/billchurch/WebSSH2/commit/8c55c83)) +* config change `privatekey` to `privateKey` for consistency with ssh2 module ([a176167](https://github.com/billchurch/WebSSH2/commit/a176167)) +* config move algorithims to ssh property ([52a989b](https://github.com/billchurch/WebSSH2/commit/52a989b)) +* pass full ssh error to browser ([27d9bfb](https://github.com/billchurch/WebSSH2/commit/27d9bfb)) +* username/password in config file no longer honored [#374](https://github.com/billchurch/WebSSH2/issues/374) ([4185df7](https://github.com/billchurch/WebSSH2/commit/4185df7)) + + +### Features + +* accept private key from client [#381](https://github.com/billchurch/WebSSH2/issues/381) ([829b5cd](https://github.com/billchurch/WebSSH2/commit/829b5cd)) +* add `ssh.disableInteractiveAuth` feature in support of [#379](https://github.com/billchurch/WebSSH2/issues/379) ([c7dfad0](https://github.com/billchurch/WebSSH2/commit/c7dfad0)) +* allow passphrase encrypted ssh keys from client [#381](https://github.com/billchurch/WebSSH2/issues/381) ([056e87b](https://github.com/billchurch/WebSSH2/commit/056e87b)) +* Allow setting environment variables from the URL [#371](https://github.com/billchurch/WebSSH2/issues/371) ([6ec0490](https://github.com/billchurch/WebSSH2/commit/6ec0490)) +* implement ssh private key auth [#379](https://github.com/billchurch/WebSSH2/issues/379) ([402b678](https://github.com/billchurch/WebSSH2/commit/402b678)) +* passphrase encrypted private key authentication [#382](https://github.com/billchurch/WebSSH2/issues/382) ([7961451](https://github.com/billchurch/WebSSH2/commit/7961451)) +* support uploading of ssh-rsa private key from client for authentication [#381](https://github.com/billchurch/WebSSH2/issues/381) ([2f4083f](https://github.com/billchurch/WebSSH2/commit/2f4083f)) +* update jsmasker to v1.4.0 ([3315df1](https://github.com/billchurch/WebSSH2/commit/3315df1)) +* update webssh_client to 0.2.26 ([a1b2e56](https://github.com/billchurch/WebSSH2/commit/a1b2e56)) +* update webssh2_client to 0.2.27 ([b511ce5](https://github.com/billchurch/WebSSH2/commit/b511ce5)) +* webssh2_client to 0.2.28 ([b4b7429](https://github.com/billchurch/WebSSH2/commit/b4b7429)) + + + ## [0.2.22](https://github.com/billchurch/WebSSH2/compare/v0.2.21...v0.2.22) (2024-11-30) @@ -539,4 +579,4 @@ Mostly client (browser) related changes in this release ### Added -- Initial proof of concept and release. For historical purposes only. +- Initial proof of concept and release. For historical purposes only. \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d224308..dbfc5ab 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -149,7 +149,7 @@ If you encounter issues: 4. Verify the ports (3000 and 2222) are available 5. Clear browser cache if changes aren't reflecting -## Building for Production +## Building for Production (client) When ready to build for production: diff --git a/README.md b/README.md index 633c84e..b0f7862 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ For more information on SSH keyboard-interactive authentication, refer to [RFC 4 ### SSH Private Key Authentication -WebSSH2 supports SSH private key authentication when using the `/ssh/host/` endpoint with a private key configured in the server settings. +WebSSH2 supports SSH private key authentication when using the `/ssh/host/` endpoint with a private key configured in the server settings or via the interactive method with the `/ssh/` endpoint. #### Configuration @@ -215,13 +215,17 @@ Private key authentication can only be configured through the `config.json` file #### Key Requirements - Only `ssh-rsa` type keys are supported +- Passphrase encryption is supported, and if used the `passphrase` must be provided - The private key must be in PEM format - The key in `config.json` must be on a single line with `\n` as line separators - Must include the appropriate header and footer: ``` -----BEGIN RSA PRIVATE KEY-----\n[... key content ...]\n-----END RSA PRIVATE KEY----- ``` - + or for encrypted keys: + ``` + -----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,5930F19760F7FBBC865400940A89D954\n\n[... key content ...]\n-----END RSA PRIVATE KEY----- + ``` #### Generating a Private Key To generate a new SSH private key, you can use the following command: @@ -231,10 +235,10 @@ ssh-keygen -m PEM -t rsa -b 4096 -f ~/.ssh/id_rsa #### Converting Your Private Key -To convert your existing SSH private key into the correct format for `config.json`, you can use this bash command: +Keys uploaded or pasted using the interactive mode through the `/ssh` endpoint can work as-is, however if using a key with `config.json` you must convert your existing SSH private key into the correct format (single line). A bash one-liner you can to accomplish this is: ```bash -echo '"'$(cat ~/.ssh/id_rsa | tr '\n' '~' | sed 's/~/\\n/g')'"' +echo ' "privateKey": "'$(cat ~/.ssh/id_rsa | tr '\n' '~' | sed 's/~/\\n/g')'"' ``` This command: diff --git a/app/app.js b/app/app.js index 6b9dfe7..8e9e9c0 100644 --- a/app/app.js +++ b/app/app.js @@ -3,6 +3,7 @@ import express from 'express' import config from './config.js' +import SSHConnection from './ssh.js' import socketHandler from './socket.js' import { createRoutes } from './routes.js' import { applyMiddleware } from './middleware.js' @@ -52,7 +53,7 @@ function initializeServer() { const io = configureSocketIO(server, sessionMiddleware, config) // Set up Socket.IO listeners - socketHandler(io, config) + socketHandler(io, config, SSHConnection) // Start the server startServer(server, config) diff --git a/app/config.js b/app/config.js index ae0fd29..86776d0 100644 --- a/app/config.js +++ b/app/config.js @@ -23,6 +23,8 @@ const defaultConfig = { user: { name: null, password: null, + privateKey: null, + passphrase: null, }, ssh: { host: null, diff --git a/app/configSchema.js b/app/configSchema.js index c4692e6..60dc100 100644 --- a/app/configSchema.js +++ b/app/configSchema.js @@ -28,6 +28,7 @@ const configSchema = { name: { type: ['string', 'null'] }, password: { type: ['string', 'null'] }, privateKey: { type: ['string', 'null'] }, + passphrase: { type: ['string', 'null'] }, }, required: ['name', 'password'], }, diff --git a/app/socket.js b/app/socket.js index a897ef4..1b1cfd8 100644 --- a/app/socket.js +++ b/app/socket.js @@ -3,20 +3,27 @@ import validator from 'validator' import { EventEmitter } from 'events' -import SSHConnection from './ssh.js' import { createNamespacedDebug } from './logger.js' import { SSHConnectionError, handleError } from './errors.js' -const debug = createNamespacedDebug('socket') import { isValidCredentials, maskSensitiveData, validateSshTerm } from './utils.js' import { MESSAGES } from './constants.js' +const debug = createNamespacedDebug('socket') + class WebSSH2Socket extends EventEmitter { - constructor(socket, config) { + /** + * Creates a new WebSSH2Socket instance + * @param {Object} socket - The Socket.IO socket instance + * @param {Object} config - The application configuration + * @param {Function} SSHConnectionClass - The SSH connection class constructor + */ + constructor(socket, config, SSHConnectionClass) { super() this.socket = socket this.config = config - this.ssh = new SSHConnection(config) + this.SSHConnectionClass = SSHConnectionClass + this.ssh = null this.sessionState = { authenticated: false, username: null, @@ -58,10 +65,6 @@ class WebSSH2Socket extends EventEmitter { this.socket.emit('authentication', { action: 'request_auth' }) } - this.ssh.on('keyboard-interactive', (data) => { - this.handleKeyboardInteractive(data) - }) - this.socket.on('authenticate', (creds) => { this.handleAuthenticate(creds) }) @@ -129,6 +132,14 @@ class WebSSH2Socket extends EventEmitter { creds.privateKey = this.config.user.privateKey } + // Create new SSH connection instance + this.ssh = new this.SSHConnectionClass(this.config) + + // Set up SSH event handlers + this.ssh.on("keyboard-interactive", data => { + this.handleKeyboardInteractive(data) + }) + this.ssh .connect(creds) .then(() => { @@ -342,6 +353,6 @@ class WebSSH2Socket extends EventEmitter { } } -export default function (io, config) { - io.on('connection', (socket) => new WebSSH2Socket(socket, config)) +export default function (io, config, SSHConnectionClass) { + io.on('connection', (socket) => new WebSSH2Socket(socket, config, SSHConnectionClass)) } diff --git a/app/ssh.js b/app/ssh.js index 02d24c8..5898db4 100644 --- a/app/ssh.js +++ b/app/ssh.js @@ -25,14 +25,30 @@ class SSHConnection extends EventEmitter { } /** - * Validates the format of an RSA private key + * Validates the format of an RSA private key, supporting both standard and encrypted keys * @param {string} key - The private key string to validate * @returns {boolean} - Whether the key appears to be valid */ validatePrivateKey(key) { - const keyPattern = + // Pattern for standard RSA private key + const standardKeyPattern = /^-----BEGIN (?:RSA )?PRIVATE KEY-----\r?\n([A-Za-z0-9+/=\r\n]+)\r?\n-----END (?:RSA )?PRIVATE KEY-----\r?\n?$/ - return keyPattern.test(key) + + // Pattern for encrypted RSA private key + const encryptedKeyPattern = + /^-----BEGIN RSA PRIVATE KEY-----\r?\n(?:Proc-Type: 4,ENCRYPTED\r?\nDEK-Info: ([^\r\n]+)\r?\n\r?\n)([A-Za-z0-9+/=\r\n]+)\r?\n-----END RSA PRIVATE KEY-----\r?\n?$/ + + // Test for either standard or encrypted key format + return standardKeyPattern.test(key) || encryptedKeyPattern.test(key) + } + + /** + * Checks if a private key is encrypted + * @param {string} key - The private key to check + * @returns {boolean} - Whether the key is encrypted + */ + isEncryptedKey(key) { + return key.includes('Proc-Type: 4,ENCRYPTED') } /** @@ -126,12 +142,68 @@ class SSHConnection extends EventEmitter { this.handleKeyboardInteractive(name, instructions, lang, prompts, finish) }) } + /** + * Handles keyboard-interactive authentication prompts. + * @param {string} name - The name of the authentication request. + * @param {string} instructions - The instructions for the keyboard-interactive prompt. + * @param {string} lang - The language of the prompt. + * @param {Array} prompts - The list of prompts provided by the server. + * @param {Function} finish - The callback to complete the keyboard-interactive authentication. + */ + + handleKeyboardInteractive(name, instructions, lang, prompts, finish) { + debug('handleKeyboardInteractive: Keyboard-interactive auth %O', prompts) + + // Check if we should always send prompts to the client + if (this.config.ssh.alwaysSendKeyboardInteractivePrompts) { + this.sendPromptsToClient(name, instructions, prompts, finish) + return + } + + const responses = [] + let shouldSendToClient = false + + for (let i = 0; i < prompts.length; i += 1) { + if (prompts[i].prompt.toLowerCase().includes('password') && this.creds.password) { + responses.push(this.creds.password) + } else { + shouldSendToClient = true + break + } + } + + if (shouldSendToClient) { + this.sendPromptsToClient(name, instructions, prompts, finish) + } else { + finish(responses) + } + } + + /** + * Sends prompts to the client for keyboard-interactive authentication. + * + * @param {string} name - The name of the authentication method. + * @param {string} instructions - The instructions for the authentication. + * @param {Array<{ prompt: string, echo: boolean }>} prompts - The prompts to be sent to the client. + * @param {Function} finish - The callback function to be called when the client responds. + */ + sendPromptsToClient(name, instructions, prompts, finish) { + this.emit('keyboard-interactive', { + name: name, + instructions: instructions, + prompts: prompts.map((p) => ({ prompt: p.prompt, echo: p.echo })), + }) + + this.once('keyboard-interactive-response', (responses) => { + finish(responses) + }) + } /** * Generates the SSH configuration object based on credentials. - * @param {Object} creds - The credentials object containing host, port, username, and optional password. + * @param {Object} creds - The credentials object * @param {boolean} useKey - Whether to attempt key authentication - * @returns {Object} - The SSH configuration object. + * @returns {Object} - The SSH configuration object */ getSSHConfig(creds, useKey) { const config = { @@ -154,6 +226,16 @@ class SSHConnection extends EventEmitter { throw new SSHConnectionError('Invalid private key format') } config.privateKey = privateKey + + // Check if key is encrypted and passphrase is needed + if (this.isEncryptedKey(privateKey)) { + const passphrase = creds.passphrase || this.config.user.passphrase + if (!passphrase) { + throw new SSHConnectionError('Encrypted private key requires a passphrase') + } + debug('Adding passphrase for encrypted private key') + config.passphrase = passphrase + } } else if (creds.password) { debug('Using password authentication') config.password = creds.password diff --git a/app/utils.js b/app/utils.js index 1afa32c..fe3fcf7 100644 --- a/app/utils.js +++ b/app/utils.js @@ -80,7 +80,7 @@ export function getValidatedPort(portInput) { * - port (number) * AND either: * - password (string) OR - * - privateKey/privateKey (string) + * - privateKey (string) with optional passphrase (string) * * @param {Object} creds - The credentials object. * @returns {boolean} - Returns true if the credentials are valid, otherwise false. @@ -97,11 +97,14 @@ export function isValidCredentials(creds) { return false } - // Must have either password or privateKey/privateKey + // Must have either password or privateKey const hasPassword = typeof creds.password === 'string' - const hasPrivateKey = typeof creds.privateKey === 'string' || typeof creds.privateKey === 'string' + const hasPrivateKey = typeof creds.privateKey === 'string' - return hasPassword || hasPrivateKey + // Passphrase is optional but must be string if provided + const hasValidPassphrase = !creds.passphrase || typeof creds.passphrase === 'string' + + return (hasPassword || hasPrivateKey) && hasValidPassphrase } /** @@ -171,7 +174,9 @@ export function modifyHtml(html, config) { * @returns {Object} The masked object */ export function maskSensitiveData(obj, options) { - const defaultOptions = {} + const defaultOptions = { + properties: ['password', 'privateKey', 'passphrase', 'key', 'secret', 'token'], + } debug('maskSensitiveData') const maskingOptions = Object.assign({}, defaultOptions, options || {}) diff --git a/config.json.sample b/config.json.sample index d41d63c..59c981e 100644 --- a/config.json.sample +++ b/config.json.sample @@ -26,7 +26,7 @@ "keepaliveCountMax": 10, "allowedSubnets": [], "alwaysSendKeyboardInteractivePrompts": false, - "disableInteractiveAuth": true, + "disableInteractiveAuth": false, "algorithms": { "cipher": [ "aes128-ctr", diff --git a/package.json b/package.json index a881d64..71d5d61 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "lint:fix": "eslint app --fix", "watch": "NODE_ENV=development DEBUG=webssh* node --watch index.js", "test": "node --test tests/*.test.js", - "release": "standard-version -a -s --release-as patch --commit-all", + "release": "npm run lint:fix && npm run test && standard-version -a -s --release-as patch --commit-all --infile ChangeLog.md", "release:dry-run": "standard-version -a -s --release-as patch --dry-run", "publish:dry-run": "npm publish --dry-run", "publish:npm": "npm publish",