Skip to content

Commit 4444abc

Browse files
authored
feat: username and password support for SSH connection type (#1126)
Signed-off-by: Joe Morris <[email protected]>
1 parent 14e870d commit 4444abc

File tree

11 files changed

+727
-115
lines changed

11 files changed

+727
-115
lines changed

client/src/components/profile.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export interface SSHProfile extends BaseProfile {
8888
saspath: string;
8989
port: number;
9090
username: string;
91+
privateKeyFilePath?: string;
9192
}
9293

9394
export interface COMProfile extends BaseProfile {
@@ -580,6 +581,15 @@ export class ProfileConfig {
580581
return;
581582
}
582583

584+
const keyPath = await createInputTextBox(
585+
ProfilePromptType.PrivateKeyFilePath,
586+
profileClone.privateKeyFilePath,
587+
);
588+
589+
if (keyPath) {
590+
profileClone.privateKeyFilePath = keyPath;
591+
}
592+
583593
await this.upsertProfile(name, profileClone);
584594
} else if (profileClone.connectionType === ConnectionType.COM) {
585595
profileClone.sasOptions = [];
@@ -657,6 +667,7 @@ export enum ProfilePromptType {
657667
SASPath,
658668
Port,
659669
Username,
670+
PrivateKeyFilePath,
660671
}
661672

662673
/**
@@ -794,6 +805,11 @@ const input: ProfilePromptInput = {
794805
placeholder: l10n.t("Enter your username"),
795806
description: l10n.t("Enter your SAS server username."),
796807
},
808+
[ProfilePromptType.PrivateKeyFilePath]: {
809+
title: l10n.t("Private Key File Path (optional)"),
810+
placeholder: l10n.t("Enter the local private key file path"),
811+
description: l10n.t("To use the SSH Agent or a password, leave blank."),
812+
},
797813
};
798814

799815
/**

client/src/connection/ssh/auth.ts

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
// Copyright © 2022-2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { l10n, window } from "vscode";
4+
5+
import { readFileSync } from "fs";
6+
import {
7+
AgentAuthMethod,
8+
KeyboardInteractiveAuthMethod,
9+
ParsedKey,
10+
PasswordAuthMethod,
11+
Prompt,
12+
PublicKeyAuthMethod,
13+
utils,
14+
} from "ssh2";
15+
16+
/**
17+
* Abstraction for presenting authentication prompts to the user.
18+
*/
19+
export interface AuthPresenter {
20+
/**
21+
* Prompt the user for a passphrase.
22+
* @returns the passphrase entered by the user
23+
*/
24+
presentPasswordPrompt: (username: string) => Promise<string>;
25+
/**
26+
* Prompt the user for a password.
27+
* @returns the password entered by the user
28+
*/
29+
presentPassphrasePrompt: () => Promise<string>;
30+
/**
31+
* Present multiple prompts to the user.
32+
* This scenario can happen when the server sends multiple input prompts to the user during keyboard-interactive authentication.
33+
* Auth setups involving MFA or PAM can trigger this scenario.
34+
* One input box will be presented for each prompt.
35+
* @param prompts an array of prompts to present to the user
36+
* @returns array of answers to the prompts
37+
*/
38+
presentMultiplePrompts: (
39+
username: string,
40+
prompts: Prompt[],
41+
) => Promise<string[]>;
42+
}
43+
44+
class AuthPresenterImpl implements AuthPresenter {
45+
presentPasswordPrompt = async (username: string): Promise<string> => {
46+
return this.presentPrompt(
47+
l10n.t("Enter the password for user: {username}", { username }),
48+
l10n.t("Password Required"),
49+
true,
50+
);
51+
};
52+
53+
presentPassphrasePrompt = async (): Promise<string> => {
54+
return this.presentPrompt(
55+
l10n.t("Enter the passphrase for the private key"),
56+
l10n.t("Passphrase Required"),
57+
true,
58+
);
59+
};
60+
61+
presentMultiplePrompts = async (
62+
username: string,
63+
prompts: Prompt[],
64+
): Promise<string[]> => {
65+
const answers: string[] = [];
66+
for (const prompt of prompts) {
67+
const answer = await this.presentPrompt(
68+
undefined,
69+
l10n.t("User {username} {prompt}", {
70+
username,
71+
prompt: prompt.prompt,
72+
}),
73+
!prompt.echo,
74+
);
75+
if (answer) {
76+
answers.push(answer);
77+
}
78+
}
79+
return answers;
80+
};
81+
82+
/**
83+
* Present a secure prompt to the user.
84+
* @param prompt the prompt to display to the user
85+
* @param title optional title for the prompt
86+
* @param isSecureInput whether the input should be hidden
87+
* @returns the user's response to the prompt
88+
*/
89+
private presentPrompt = async (
90+
prompt: string,
91+
title?: string,
92+
isSecureInput?: boolean,
93+
): Promise<string> => {
94+
return window.showInputBox({
95+
ignoreFocusOut: true,
96+
prompt: prompt,
97+
title: title,
98+
password: isSecureInput,
99+
});
100+
};
101+
}
102+
103+
/**
104+
* Handles the authentication process for the ssh connection.
105+
*
106+
*/
107+
export class AuthHandler {
108+
private _authPresenter: AuthPresenter;
109+
private _keyParser: KeyParser;
110+
111+
constructor(authPresenter?: AuthPresenter, keyParser?: KeyParser) {
112+
this._authPresenter = authPresenter;
113+
this._keyParser = keyParser;
114+
115+
if (!authPresenter) {
116+
this._authPresenter = new AuthPresenterImpl();
117+
}
118+
if (!keyParser) {
119+
this._keyParser = new KeyParserImpl();
120+
}
121+
}
122+
123+
/**
124+
* Authenticate to the server using the password method.
125+
* @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server.
126+
* @param resolve a function that resolves the promise that is waiting for the password
127+
* @param username the user name to use for the connection
128+
*/
129+
passwordAuth = async (username: string): Promise<PasswordAuthMethod> => {
130+
const pw = await this._authPresenter.presentPasswordPrompt(username);
131+
return {
132+
type: "password",
133+
password: pw,
134+
username: username,
135+
};
136+
};
137+
138+
/**
139+
* Authenticate to the server using the keyboard-interactive method.
140+
* @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server.
141+
* @param resolve a function that resolves the promise that is waiting for authentication
142+
* @param username the user name to use for the connection
143+
*/
144+
keyboardInteractiveAuth = async (
145+
username: string,
146+
): Promise<KeyboardInteractiveAuthMethod> => {
147+
return {
148+
type: "keyboard-interactive",
149+
username: username,
150+
prompt: (_name, _instructions, _instructionsLang, prompts, finish) => {
151+
// often, the server will only send a single prompt for the password.
152+
// however, PAM can send multiple prompts, so we need to handle that case
153+
this._authPresenter
154+
.presentMultiplePrompts(username, prompts)
155+
.then((answers) => {
156+
finish(answers);
157+
});
158+
},
159+
};
160+
};
161+
162+
/**
163+
* Authenticate to the server using the ssh-agent. See the extension Docs for more information on how to set up the ssh-agent.
164+
* @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server.
165+
* @param username the user name to use for the connection
166+
*/
167+
sshAgentAuth = (username: string): AgentAuthMethod => {
168+
return {
169+
type: "agent",
170+
agent: process.env.SSH_AUTH_SOCK,
171+
username: username,
172+
};
173+
};
174+
175+
/**
176+
* Authenticate to the server using a private key file.
177+
* If a private key file is defined in the connection profile, this function will read the file and use it to authenticate to the server.
178+
* If the key is encrypted, the user will be prompted for the passphrase.
179+
* @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server.
180+
* @param resolve a function that resolves the promise that is waiting for authentication
181+
* @param privateKeyFilePath the path to the private key file defined in the connection profile
182+
* @param username the user name to use for the connection
183+
*/
184+
privateKeyAuth = async (
185+
privateKeyFilePath: string,
186+
username: string,
187+
): Promise<PublicKeyAuthMethod> => {
188+
// first, try to parse the key file without a passphrase
189+
const parsedKeyResult = this._keyParser.parseKey(privateKeyFilePath);
190+
const hasParseError = parsedKeyResult instanceof Error;
191+
const passphraseRequired =
192+
hasParseError &&
193+
parsedKeyResult.message ===
194+
"Encrypted private OpenSSH key detected, but no passphrase given";
195+
// key is encrypted, prompt for passphrase
196+
if (passphraseRequired) {
197+
const passphrase = await this._authPresenter.presentPassphrasePrompt();
198+
//parse the keyfile using the passphrase
199+
const passphrasedKeyContentsResult = this._keyParser.parseKey(
200+
privateKeyFilePath,
201+
passphrase,
202+
);
203+
204+
if (passphrasedKeyContentsResult instanceof Error) {
205+
throw passphrasedKeyContentsResult;
206+
} else {
207+
return {
208+
type: "publickey",
209+
key: passphrasedKeyContentsResult,
210+
passphrase: passphrase,
211+
username: username,
212+
};
213+
}
214+
} else {
215+
if (hasParseError) {
216+
throw parsedKeyResult;
217+
} else {
218+
return {
219+
type: "publickey",
220+
key: parsedKeyResult,
221+
username: username,
222+
};
223+
}
224+
}
225+
};
226+
}
227+
228+
/**
229+
* Parses a private key file.
230+
*/
231+
export interface KeyParser {
232+
/**
233+
* Parse the private key file.
234+
* If a passphrase is specified, the key will be decrypted using the passphrase.
235+
* @param privateKeyPath the path to the private key file
236+
* @param passphrase the passphrase to decrypt the key if applicable
237+
* @returns the parsed key or an error if the key could not be parsed
238+
*/
239+
parseKey: (privateKeyPath: string, passphrase?: string) => ParsedKey | Error;
240+
}
241+
242+
class KeyParserImpl implements KeyParser {
243+
private readKeyFile = (privateKeyPath: string): Buffer => {
244+
try {
245+
return readFileSync(privateKeyPath);
246+
} catch (e) {
247+
throw new Error(
248+
l10n.t("Error reading private key file: {filePath}, error: {message}", {
249+
filePath: privateKeyPath,
250+
message: e.message,
251+
}),
252+
);
253+
}
254+
};
255+
256+
public parseKey = (
257+
privateKeyPath: string,
258+
passphrase?: string,
259+
): ParsedKey | Error => {
260+
const keyContents = this.readKeyFile(privateKeyPath);
261+
return utils.parseKey(keyContents, passphrase);
262+
};
263+
}

client/src/connection/ssh/const.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
const SECOND = 1000;
5+
const MINUTE = 60 * SECOND;
6+
export const KEEPALIVE_INTERVAL = 60 * SECOND; //How often (in milliseconds) to send SSH-level keepalive packets to the server. Set to 0 to disable.
7+
export const KEEPALIVE_UNANSWERED_THRESHOLD =
8+
(15 * MINUTE) / KEEPALIVE_INTERVAL; //How many consecutive, unanswered SSH-level keepalive packets that can be sent to the server before disconnection.
9+
export const WORK_DIR_START_TAG = "WORKDIR";
10+
export const WORK_DIR_END_TAG = "WORKDIREND";
11+
export const CONNECT_READY_TIMEOUT = 5 * MINUTE; //allow extra time due to possible prompting

0 commit comments

Comments
 (0)