feat: implement ssh private key auth #379

This commit is contained in:
Bill Church 2024-11-29 14:04:42 +00:00
parent 03d286a56a
commit 402b678b37
No known key found for this signature in database
3 changed files with 216 additions and 101 deletions

View file

@ -153,13 +153,25 @@ class WebSSH2Socket extends EventEmitter {
this.socket.emit("getTerminal", true) this.socket.emit("getTerminal", true)
}) })
.catch(err => { .catch((err) => {
debug( debug(
`initializeConnection: SSH CONNECTION ERROR: ${this.socket.id}, Host: ${creds.host}, Error: ${err.message}` `initializeConnection: SSH CONNECTION ERROR: ${this.socket.id}, Host: ${creds.host}, Error: ${err.message}`
) )
handleError(new SSHConnectionError(`${err.message}`)) handleError(new SSHConnectionError(`${err.message}`))
this.socket.emit("ssherror", `${err.message}`) this.socket.emit("ssherror", `${err.message}`)
}) })
// Set up password prompt handler
this.ssh.on("password-prompt", (data) => {
this.socket.emit("authentication", {
action: "password_prompt",
message: `Key authentication failed. Please enter password for ${data.username}@${data.host}`
})
})
this.socket.on("password_response", (password) => {
this.ssh.emit("password-response", password)
})
} }
/** /**

View file

@ -14,21 +14,35 @@ const debug = createNamespacedDebug("ssh")
* @extends EventEmitter * @extends EventEmitter
*/ */
class SSHConnection extends EventEmitter { class SSHConnection extends EventEmitter {
/**
* Create an SSHConnection.
* @param {Object} config - Configuration object for the SSH connection.
*/
constructor(config) { constructor(config) {
super() super()
this.config = config this.config = config
this.conn = null this.conn = null
this.stream = null this.stream = null
this.creds = null this.creds = null
this.authAttempts = 0
this.maxAuthAttempts = 2
}
/**
* Validates the format of an RSA private key
* @param {string} key - The private key string to validate
* @returns {boolean} - Whether the key appears to be valid
*/
validatePrivateKey(key) {
const keyStart = "-----BEGIN RSA PRIVATE KEY-----"
const keyEnd = "-----END RSA PRIVATE KEY-----"
return (
typeof key === "string" &&
key.includes(keyStart) &&
key.includes(keyEnd) &&
key.trim().length > keyStart.length + keyEnd.length
)
} }
/** /**
* Connects to the SSH server using the provided credentials. * Connects to the SSH server using the provided credentials.
* @param {Object} creds - The credentials object containing host, port, username, and password. * @param {Object} creds - The credentials object containing host, port, username, and optional password.
* @returns {Promise<SSH>} - A promise that resolves with the SSH connection instance. * @returns {Promise<SSH>} - A promise that resolves with the SSH connection instance.
*/ */
connect(creds) { connect(creds) {
@ -40,115 +54,116 @@ class SSHConnection extends EventEmitter {
} }
this.conn = new SSH() this.conn = new SSH()
this.authAttempts = 0
const sshConfig = this.getSSHConfig(creds) // First try with key authentication if available
const sshConfig = this.getSSHConfig(creds, true)
this.conn.on("ready", () => { this.setupConnectionHandlers(resolve, reject)
debug(`connect: ready: ${creds.host}`)
resolve(this.conn)
})
this.conn.on("end", () => { try {
debug(`connect: end: `) this.conn.connect(sshConfig)
}) } catch (err) {
reject(new SSHConnectionError(`Connection failed: ${err.message}`))
}
})
}
this.conn.on("close", () => { /**
debug(`connect: close: `) * Sets up SSH connection event handlers
}) * @param {Function} resolve - Promise resolve function
* @param {Function} reject - Promise reject function
*/
setupConnectionHandlers(resolve, reject) {
this.conn.on("ready", () => {
debug(`connect: ready: ${this.creds.host}`)
resolve(this.conn)
})
this.conn.on("error", err => { this.conn.on("end", () => {
const error = new SSHConnectionError(`${err.message}`) debug("connect: end")
})
this.conn.on("close", () => {
debug("connect: close")
})
this.conn.on("error", (err) => {
debug(`connect: error: ${err.message}`)
// Check if this is an authentication error and we haven't exceeded max attempts
if (this.authAttempts < this.maxAuthAttempts) {
this.authAttempts++
debug(
`Authentication attempt ${this.authAttempts} failed, trying password authentication`
)
// Only try password auth if we have a password
if (this.creds.password) {
debug("Retrying with password authentication")
// Disconnect current connection
if (this.conn) {
this.conn.end()
}
// Create new connection with password authentication
this.conn = new SSH()
const passwordConfig = this.getSSHConfig(this.creds, false)
this.setupConnectionHandlers(resolve, reject)
this.conn.connect(passwordConfig)
} else {
// No password available, emit event to request password
debug("No password available, requesting password from client")
this.emit("password-prompt", {
host: this.creds.host,
username: this.creds.username
})
// Listen for password response one time
this.once("password-response", (password) => {
this.creds.password = password
const newConfig = this.getSSHConfig(this.creds, false)
this.setupConnectionHandlers(resolve, reject)
this.conn.connect(newConfig)
})
}
} else {
// We've exhausted all authentication attempts
const error = new SSHConnectionError(
"All authentication methods failed"
)
handleError(error) handleError(error)
reject(error) reject(error)
})
this.conn.on(
"keyboard-interactive",
(name, instructions, lang, prompts, finish) => {
this.handleKeyboardInteractive(
name,
instructions,
lang,
prompts,
finish
)
}
)
this.conn.connect(sshConfig)
})
}
/**
* 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 => { this.conn.on(
finish(responses) "keyboard-interactive",
}) (name, instructions, lang, prompts, finish) => {
this.handleKeyboardInteractive(
name,
instructions,
lang,
prompts,
finish
)
}
)
} }
/** /**
* 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 password. * @param {Object} creds - The credentials object containing host, port, username, and optional password.
* @param {boolean} useKey - Whether to attempt key authentication
* @returns {Object} - The SSH configuration object. * @returns {Object} - The SSH configuration object.
*/ */
getSSHConfig(creds) { getSSHConfig(creds, useKey) {
return { const config = {
host: creds.host, host: creds.host,
port: creds.port, port: creds.port,
username: creds.username, username: creds.username,
password: creds.password,
tryKeyboard: true, tryKeyboard: true,
algorithms: this.config.ssh.algorithms, algorithms: this.config.ssh.algorithms,
readyTimeout: this.config.ssh.readyTimeout, readyTimeout: this.config.ssh.readyTimeout,
@ -156,6 +171,20 @@ class SSHConnection extends EventEmitter {
keepaliveCountMax: this.config.ssh.keepaliveCountMax, keepaliveCountMax: this.config.ssh.keepaliveCountMax,
debug: createNamespacedDebug("ssh2") debug: createNamespacedDebug("ssh2")
} }
// If useKey is true and we have a private key, use it
if (useKey && this.config.user.privatekey) {
debug("Using private key authentication")
if (!this.validatePrivateKey(this.config.user.privatekey)) {
throw new SSHConnectionError("Invalid private key format")
}
config.privateKey = this.config.user.privatekey
} else if (creds.password) {
debug("Using password authentication")
config.password = creds.password
}
return config
} }
/** /**

View file

@ -39,6 +39,11 @@ describe("SSHConnection", () => {
readyTimeout: 20000, readyTimeout: 20000,
keepaliveInterval: 60000, keepaliveInterval: 60000,
keepaliveCountMax: 10 keepaliveCountMax: 10
},
user: {
name: null,
password: null,
privatekey: null
} }
} }
sshConnection = new SSHConnection(mockConfig) sshConnection = new SSHConnection(mockConfig)
@ -56,8 +61,6 @@ describe("SSHConnection", () => {
}) })
describe("connect", () => { describe("connect", () => {
// ... previous tests ...
it("should handle connection errors", () => { it("should handle connection errors", () => {
const mockCreds = { const mockCreds = {
host: "example.com", host: "example.com",
@ -77,6 +80,79 @@ describe("SSHConnection", () => {
expect(error.message).toBe("Connection failed") expect(error.message).toBe("Connection failed")
}) })
}) })
it("should connect successfully with password", () => {
const mockCreds = {
host: "example.com",
port: 22,
username: "user",
password: "pass"
}
mockSSH2Client.on.mockImplementation((event, callback) => {
if (event === "ready") {
callback()
}
})
return sshConnection.connect(mockCreds).then(() => {
expect(mockSSH2Client.connect).toHaveBeenCalledWith(
expect.objectContaining({
host: "example.com",
port: 22,
username: "user",
password: "pass",
tryKeyboard: true
})
)
})
})
})
describe("key authentication", () => {
it("should use privateKey when provided in config", () => {
mockConfig.user.privatekey = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpA...etc\n-----END RSA PRIVATE KEY-----"
sshConnection = new SSHConnection(mockConfig)
const mockCreds = {
host: "example.com",
port: 22,
username: "user"
}
mockSSH2Client.on.mockImplementation((event, callback) => {
if (event === "ready") {
callback()
}
})
return sshConnection.connect(mockCreds).then(() => {
expect(mockSSH2Client.connect).toHaveBeenCalledWith(
expect.objectContaining({
privateKey: mockConfig.user.privatekey,
host: mockCreds.host,
port: mockCreds.port,
username: mockCreds.username
})
)
})
})
it("should handle invalid private key format", () => {
mockConfig.user.privatekey = "invalid-key-format"
sshConnection = new SSHConnection(mockConfig)
const mockCreds = {
host: "example.com",
port: 22,
username: "user"
}
return sshConnection.connect(mockCreds).catch(error => {
expect(error).toBeInstanceOf(SSHConnectionError)
expect(error.message).toBe("Invalid private key format")
})
})
}) })
describe("shell", () => { describe("shell", () => {
@ -127,7 +203,6 @@ describe("SSHConnection", () => {
sshConnection.stream = null sshConnection.stream = null
sshConnection.resizeTerminal(80, 24) sshConnection.resizeTerminal(80, 24)
// No error should be thrown // No error should be thrown
}) })
}) })
@ -153,8 +228,7 @@ describe("SSHConnection", () => {
sshConnection.conn = null sshConnection.conn = null
sshConnection.end() sshConnection.end()
// No error should be thrown // No error should be thrown
}) })
}) })
}) })