feat: allow passphrase encrypted ssh keys from client #381

This commit is contained in:
Bill Church 2024-12-03 18:50:24 +00:00
parent b4b74297ea
commit 056e87b40d
No known key found for this signature in database
2 changed files with 99 additions and 14 deletions

View file

@ -25,14 +25,19 @@ 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 * @param {string} key - The private key string to validate
* @returns {boolean} - Whether the key appears to be valid * @returns {boolean} - Whether the key appears to be valid
*/ */
validatePrivateKey(key) { validatePrivateKey(key) {
const keyPattern = // Pattern for standard RSA private key
/^-----BEGIN (?:RSA )?PRIVATE KEY-----\r?\n([A-Za-z0-9+/=\r\n]+)\r?\n-----END (?:RSA )?PRIVATE KEY-----\r?\n?$/ 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)
} }
/** /**
@ -76,7 +81,7 @@ class SSHConnection extends EventEmitter {
resolve(this.conn) resolve(this.conn)
}) })
this.conn.on("error", (err) => { this.conn.on("error", err => {
debug(`connect: error: ${err.message}`) debug(`connect: error: ${err.message}`)
// Check if this is an authentication error and we haven't exceeded max attempts // Check if this is an authentication error and we haven't exceeded max attempts
@ -109,7 +114,7 @@ class SSHConnection extends EventEmitter {
}) })
// Listen for password response one time // Listen for password response one time
this.once("password-response", (password) => { this.once("password-response", password => {
this.creds.password = password this.creds.password = password
const newConfig = this.getSSHConfig(this.creds, false) const newConfig = this.getSSHConfig(this.creds, false)
this.setupConnectionHandlers(resolve, reject) this.setupConnectionHandlers(resolve, reject)
@ -140,9 +145,69 @@ class SSHConnection extends EventEmitter {
) )
} }
/**
* 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<Object>} 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. * 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 containing host, port, username, and optional password/privateKey/passphrase.
* @param {boolean} useKey - Whether to attempt key authentication * @param {boolean} useKey - Whether to attempt key authentication
* @returns {Object} - The SSH configuration object. * @returns {Object} - The SSH configuration object.
*/ */
@ -163,10 +228,18 @@ class SSHConnection extends EventEmitter {
if (useKey && (creds.privateKey || this.config.user.privateKey)) { if (useKey && (creds.privateKey || this.config.user.privateKey)) {
debug("Using private key authentication") debug("Using private key authentication")
const privateKey = creds.privateKey || this.config.user.privateKey const privateKey = creds.privateKey || this.config.user.privateKey
if (!this.validatePrivateKey(privateKey)) { if (!this.validatePrivateKey(privateKey)) {
throw new SSHConnectionError("Invalid private key format") throw new SSHConnectionError("Invalid private key format")
} }
config.privateKey = privateKey config.privateKey = privateKey
// Add passphrase if provided
if (creds.passphrase) {
debug("Passphrase provided for private key")
config.passphrase = creds.passphrase
}
} else if (creds.password) { } else if (creds.password) {
debug("Using password authentication") debug("Using password authentication")
config.password = creds.password config.password = creds.password
@ -234,7 +307,7 @@ class SSHConnection extends EventEmitter {
} }
if (envVars) { if (envVars) {
Object.keys(envVars).forEach((key) => { Object.keys(envVars).forEach(key => {
env[key] = envVars[key] env[key] = envVars[key]
}) })
} }

View file

@ -87,7 +87,7 @@ function getValidatedPort(portInput) {
* - port (number) * - port (number)
* AND either: * AND either:
* - password (string) OR * - password (string) OR
* - privateKey/privateKey (string) * - privateKey (string) with optional passphrase (string)
* *
* @param {Object} creds - The credentials object. * @param {Object} creds - The credentials object.
* @returns {boolean} - Returns true if the credentials are valid, otherwise false. * @returns {boolean} - Returns true if the credentials are valid, otherwise false.
@ -104,12 +104,15 @@ function isValidCredentials(creds) {
return false return false
} }
// Must have either password or privateKey/privateKey // Must have either password or privateKey
const hasPassword = typeof creds.password === "string" const hasPassword = typeof creds.password === "string"
const hasPrivateKey = const hasPrivateKey = typeof creds.privateKey === "string"
typeof creds.privateKey === "string" || 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
} }
/** /**
@ -185,7 +188,16 @@ function modifyHtml(html, config) {
* @returns {Object} The masked object * @returns {Object} The masked object
*/ */
function maskSensitiveData(obj, options) { function maskSensitiveData(obj, options) {
const defaultOptions = {} const defaultOptions = {
properties: [
"password",
"privateKey",
"passphrase",
"key",
"secret",
"token"
]
}
debug("maskSensitiveData") debug("maskSensitiveData")
const maskingOptions = Object.assign({}, defaultOptions, options || {}) const maskingOptions = Object.assign({}, defaultOptions, options || {})