Skip to content

Commit

Permalink
feat: allow passphrase encrypted ssh keys from client #381
Browse files Browse the repository at this point in the history
  • Loading branch information
billchurch committed Dec 3, 2024
1 parent b4b7429 commit 056e87b
Show file tree
Hide file tree
Showing 2 changed files with 99 additions and 14 deletions.
89 changes: 81 additions & 8 deletions app/ssh.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
* @returns {boolean} - Whether the key appears to be valid
*/
validatePrivateKey(key) {
const keyPattern =
/^-----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 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?$/

// 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)
}

/**
Expand Down Expand Up @@ -76,7 +81,7 @@ class SSHConnection extends EventEmitter {
resolve(this.conn)
})

this.conn.on("error", (err) => {
this.conn.on("error", err => {
debug(`connect: error: ${err.message}`)

// Check if this is an authentication error and we haven't exceeded max attempts
Expand Down Expand Up @@ -109,7 +114,7 @@ class SSHConnection extends EventEmitter {
})

// Listen for password response one time
this.once("password-response", (password) => {
this.once("password-response", password => {
this.creds.password = password
const newConfig = this.getSSHConfig(this.creds, false)
this.setupConnectionHandlers(resolve, reject)
Expand Down Expand Up @@ -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.
* @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
* @returns {Object} - The SSH configuration object.
*/
Expand All @@ -163,10 +228,18 @@ class SSHConnection extends EventEmitter {
if (useKey && (creds.privateKey || this.config.user.privateKey)) {
debug("Using private key authentication")
const privateKey = creds.privateKey || this.config.user.privateKey

if (!this.validatePrivateKey(privateKey)) {
throw new SSHConnectionError("Invalid private key format")
}

config.privateKey = privateKey

// Add passphrase if provided
if (creds.passphrase) {
debug("Passphrase provided for private key")
config.passphrase = creds.passphrase
}
} else if (creds.password) {
debug("Using password authentication")
config.password = creds.password
Expand Down Expand Up @@ -234,7 +307,7 @@ class SSHConnection extends EventEmitter {
}

if (envVars) {
Object.keys(envVars).forEach((key) => {
Object.keys(envVars).forEach(key => {
env[key] = envVars[key]
})
}
Expand Down
24 changes: 18 additions & 6 deletions app/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ 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.
Expand All @@ -104,12 +104,15 @@ 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
}

/**
Expand Down Expand Up @@ -185,7 +188,16 @@ function modifyHtml(html, config) {
* @returns {Object} The masked object
*/
function maskSensitiveData(obj, options) {
const defaultOptions = {}
const defaultOptions = {
properties: [
"password",
"privateKey",
"passphrase",
"key",
"secret",
"token"
]
}
debug("maskSensitiveData")

const maskingOptions = Object.assign({}, defaultOptions, options || {})
Expand Down

0 comments on commit 056e87b

Please sign in to comment.