Skip to content

Commit 72a07b7

Browse files
mustard-mhjeanp413
andauthored
Get ssh identity file from ssh config and ssh agent (#11)
Co-authored-by: jeanp413 <[email protected]>
1 parent 0b5a665 commit 72a07b7

File tree

7 files changed

+198
-44
lines changed

7 files changed

+198
-44
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@
147147
"node-fetch": "2.6.7",
148148
"pkce-challenge": "^3.0.0",
149149
"semver": "^7.3.7",
150+
"ssh-config": "^4.1.6",
150151
"ssh2": "^1.10.0",
151152
"tmp": "^0.2.1",
152153
"uuid": "8.1.0",

src/common/files.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import * as fs from 'fs';
7+
import * as os from 'os';
8+
9+
const homeDir = os.homedir();
710

811
export async function exists(path: string) {
912
try {
@@ -13,3 +16,7 @@ export async function exists(path: string) {
1316
return false;
1417
}
1518
}
19+
20+
export function untildify(path: string){
21+
return path.replace(/^~(?=$|\/|\\)/, homeDir);
22+
}

src/common/platform.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Gitpod. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
export const isWindows = process.platform === 'win32';
7+
export const isMacintosh = process.platform === 'darwin';
8+
export const isLinux = process.platform === 'linux';

src/remoteConnector.ts

Lines changed: 94 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import * as http from 'http';
1313
import * as net from 'net';
1414
import * as crypto from 'crypto';
1515
import fetch, { Response } from 'node-fetch';
16-
import { Client as sshClient, utils as sshUtils } from 'ssh2';
16+
import { Client as sshClient, OpenSSHAgent, utils as sshUtils } from 'ssh2';
17+
import { ParsedKey } from 'ssh2-streams';
1718
import * as tmp from 'tmp';
1819
import * as path from 'path';
1920
import * as vscode from 'vscode';
@@ -22,9 +23,12 @@ import { Disposable } from './common/dispose';
2223
import { withServerApi } from './internalApi';
2324
import TelemetryReporter from './telemetryReporter';
2425
import { addHostToHostFile, checkNewHostInHostkeys } from './ssh/hostfile';
25-
import { checkDefaultIdentityFiles } from './ssh/identityFiles';
26+
import { DEFAULT_IDENTITY_FILES } from './ssh/identityFiles';
2627
import { HeartbeatManager } from './heartbeat';
2728
import { getGitpodVersion, isFeatureSupported } from './featureSupport';
29+
import SSHConfiguration from './ssh/sshConfig';
30+
import { isWindows } from './common/platform';
31+
import { untildify } from './common/files';
2832

2933
interface SSHConnectionParams {
3034
workspaceId: string;
@@ -429,6 +433,86 @@ export default class RemoteConnector extends Disposable {
429433
}
430434
}
431435

436+
// From https://github.com/openssh/openssh-portable/blob/acb2059febaddd71ee06c2ebf63dcf211d9ab9f2/sshconnect2.c#L1689-L1690
437+
private async getIdentityKeys(hostConfig: Record<string, string>) {
438+
const identityFiles: string[] = ((hostConfig['IdentityFile'] as unknown as string[]) || []).map(untildify);
439+
if (identityFiles.length === 0) {
440+
identityFiles.push(...DEFAULT_IDENTITY_FILES);
441+
}
442+
443+
const identityFileContentsResult = await Promise.allSettled(identityFiles.map(async path => fs.promises.readFile(path + '.pub')));
444+
const fileKeys = identityFileContentsResult.map((result, i) => {
445+
if (result.status === 'rejected') {
446+
return undefined;
447+
}
448+
449+
const parsedResult = sshUtils.parseKey(result.value);
450+
if (parsedResult instanceof Error || !parsedResult) {
451+
this.logger.error(`Error while parsing SSH public key ${identityFiles[i] + '.pub'}:`, parsedResult);
452+
return undefined;
453+
}
454+
455+
const parsedKey = Array.isArray(parsedResult) ? parsedResult[0] : parsedResult;
456+
const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64');
457+
458+
return {
459+
filename: identityFiles[i],
460+
parsedKey,
461+
fingerprint
462+
};
463+
}).filter(<T>(v: T | undefined): v is T => !!v);
464+
465+
let sshAgentParsedKeys: ParsedKey[] = [];
466+
try {
467+
let sshAgentSock = isWindows ? '\\\\.\\pipe\\openssh-ssh-agent' : (hostConfig['IdentityAgent'] || process.env['SSH_AUTH_SOCK']);
468+
if (!sshAgentSock) {
469+
throw new Error(`SSH_AUTH_SOCK environment variable not defined`);
470+
}
471+
sshAgentSock = untildify(sshAgentSock);
472+
473+
sshAgentParsedKeys = await new Promise<ParsedKey[]>((resolve, reject) => {
474+
const sshAgent = new OpenSSHAgent(sshAgentSock!);
475+
sshAgent.getIdentities((err, publicKeys) => {
476+
if (err) {
477+
reject(err);
478+
} else {
479+
resolve(publicKeys || []);
480+
}
481+
});
482+
});
483+
} catch (e) {
484+
this.logger.error(`Couldn't get identities from OpenSSH agent`, e);
485+
}
486+
487+
const sshAgentKeys = sshAgentParsedKeys.map(parsedKey => {
488+
const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64');
489+
return {
490+
filename: parsedKey.comment,
491+
parsedKey,
492+
fingerprint
493+
};
494+
});
495+
496+
const identitiesOnly = (hostConfig['IdentitiesOnly'] || '').toLowerCase() === 'yes';
497+
const agentKeys: { filename: string; parsedKey: ParsedKey; fingerprint: string }[] = [];
498+
const preferredIdentityKeys: { filename: string; parsedKey: ParsedKey; fingerprint: string }[] = [];
499+
for (const agentKey of sshAgentKeys) {
500+
const foundIdx = fileKeys.findIndex(k => agentKey.parsedKey.type === k.parsedKey.type && agentKey.fingerprint === k.fingerprint);
501+
if (foundIdx >= 0) {
502+
preferredIdentityKeys.push(fileKeys[foundIdx]);
503+
fileKeys.splice(foundIdx, 1);
504+
} else if (!identitiesOnly) {
505+
agentKeys.push(agentKey);
506+
}
507+
}
508+
preferredIdentityKeys.push(...agentKeys);
509+
preferredIdentityKeys.push(...fileKeys);
510+
511+
this.logger.trace(`Identity keys:`, preferredIdentityKeys.length ? preferredIdentityKeys.map(k => `${k.filename} ${k.parsedKey.type} SHA256:${k.fingerprint}`).join('\n') : 'None');
512+
513+
return preferredIdentityKeys;
514+
}
515+
432516
private async getWorkspaceSSHDestination(accessToken: string, { workspaceId, gitpodHost }: SSHConnectionParams): Promise<{ destination: string; password?: string }> {
433517
const serviceUrl = new URL(gitpodHost);
434518
const gitpodVersion = await getGitpodVersion(gitpodHost);
@@ -504,39 +588,25 @@ export default class RemoteConnector extends Disposable {
504588
this.logger.error(`Couldn't write '${sshDestInfo.hostName}' host to known_hosts file:`, e);
505589
}
506590

507-
let identityFilePaths = await checkDefaultIdentityFiles();
508-
this.logger.trace(`Default identity files:`, identityFilePaths.length ? identityFilePaths.toString() : 'None');
591+
const sshConfiguration = await SSHConfiguration.loadFromFS();
592+
const hostConfiguration = sshConfiguration.getHostConfiguration(sshDestInfo.hostName);
509593

510-
if (registeredSSHKeys) {
511-
const keyFingerprints = registeredSSHKeys.map(i => i.fingerprint);
512-
const publickKeyFiles = await Promise.allSettled(identityFilePaths.map(path => fs.promises.readFile(path + '.pub')));
513-
identityFilePaths = identityFilePaths.filter((_, index) => {
514-
const result = publickKeyFiles[index];
515-
if (result.status === 'rejected') {
516-
return false;
517-
}
594+
let identityKeys = await this.getIdentityKeys(hostConfiguration);
518595

519-
const parsedResult = sshUtils.parseKey(result.value);
520-
if (parsedResult instanceof Error || !parsedResult) {
521-
this.logger.error(`Error while parsing SSH public key${identityFilePaths[index] + '.pub'}:`, parsedResult);
522-
return false;
523-
}
596+
if (registeredSSHKeys) {
597+
this.logger.trace(`Registered public keys in Gitpod account:`, registeredSSHKeys.length ? registeredSSHKeys.map(k => `${k.name} SHA256:${k.fingerprint}`).join('\n') : 'None');
524598

525-
const parsedKey = Array.isArray(parsedResult) ? parsedResult[0] : parsedResult;
526-
const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64');
527-
return keyFingerprints.includes(fingerprint);
528-
});
529-
this.logger.trace(`Registered public keys in Gitpod account:`, identityFilePaths.length ? identityFilePaths.toString() : 'None');
599+
identityKeys = identityKeys.filter(k => !!registeredSSHKeys.find(regKey => regKey.fingerprint === k.fingerprint));
530600
} else {
531-
if (identityFilePaths.length) {
601+
if (identityKeys.length) {
532602
sshDestInfo.user = `${workspaceId}#${ownerToken}`;
533603
}
534604
this.logger.warn(`Registered SSH public keys not supported in ${gitpodHost}, using version ${gitpodVersion.version}`);
535605
}
536606

537607
return {
538608
destination: Buffer.from(JSON.stringify(sshDestInfo), 'utf8').toString('hex'),
539-
password: identityFilePaths.length === 0 ? ownerToken : undefined
609+
password: identityKeys.length === 0 ? ownerToken : undefined
540610
};
541611
}
542612

src/ssh/identityFiles.ts

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import * as os from 'os';
77
import * as path from 'path';
8-
import { exists as fileExists } from '../common/files';
98

109
const homeDir = os.homedir();
1110
const PATH_SSH_CLIENT_ID_DSA = path.join(homeDir, '.ssh', '/id_dsa');
@@ -16,22 +15,12 @@ const PATH_SSH_CLIENT_ID_XMSS = path.join(homeDir, '.ssh', '/id_xmss');
1615
const PATH_SSH_CLIENT_ID_ECDSA_SK = path.join(homeDir, '.ssh', '/id_ecdsa_sk');
1716
const PATH_SSH_CLIENT_ID_ED25519_SK = path.join(homeDir, '.ssh', '/id_ed25519_sk');
1817

19-
export async function checkDefaultIdentityFiles(): Promise<string[]> {
20-
const files = [
21-
PATH_SSH_CLIENT_ID_DSA,
22-
PATH_SSH_CLIENT_ID_ECDSA,
23-
PATH_SSH_CLIENT_ID_RSA,
24-
PATH_SSH_CLIENT_ID_ED25519,
25-
PATH_SSH_CLIENT_ID_XMSS,
26-
PATH_SSH_CLIENT_ID_ECDSA_SK,
27-
PATH_SSH_CLIENT_ID_ED25519_SK
28-
];
29-
30-
const result: string[] = [];
31-
for (const file of files) {
32-
if (await fileExists(file)) {
33-
result.push(file);
34-
}
35-
}
36-
return result;
37-
}
18+
export const DEFAULT_IDENTITY_FILES: string[] = [
19+
PATH_SSH_CLIENT_ID_RSA,
20+
PATH_SSH_CLIENT_ID_ECDSA,
21+
PATH_SSH_CLIENT_ID_ECDSA_SK,
22+
PATH_SSH_CLIENT_ID_ED25519,
23+
PATH_SSH_CLIENT_ID_ED25519_SK,
24+
PATH_SSH_CLIENT_ID_XMSS,
25+
PATH_SSH_CLIENT_ID_DSA,
26+
];

src/ssh/sshConfig.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Gitpod. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as os from 'os';
7+
import * as fs from 'fs';
8+
import * as path from 'path';
9+
import SSHConfig, { Line, Section } from 'ssh-config';
10+
import * as vscode from 'vscode';
11+
import { exists as fileExists } from '../common/files';
12+
import { isWindows } from '../common/platform';
13+
14+
const systemSSHConfig = isWindows ? path.resolve(process.env.ALLUSERSPROFILE || 'C:\\ProgramData', 'ssh\\ssh_config') : '/etc/ssh/ssh_config';
15+
const defaultSSHConfigPath = path.resolve(os.homedir(), '.ssh/config');
16+
17+
export function getSSHConfigPath() {
18+
const remoteSSHconfig = vscode.workspace.getConfiguration('remote.SSH');
19+
return remoteSSHconfig.get<string>('configFile') || defaultSSHConfigPath;
20+
}
21+
22+
function isHostSection(line: Line): line is Section {
23+
return line.type === SSHConfig.DIRECTIVE && line.param === 'Host' && !!line.value && !!(line as Section).config;
24+
}
25+
26+
export default class SSHConfiguration {
27+
28+
static async loadFromFS(): Promise<SSHConfiguration> {
29+
const sshConfigPath = getSSHConfigPath();
30+
let content = '';
31+
if (await fileExists(sshConfigPath)) {
32+
content = await fs.promises.readFile(sshConfigPath, 'utf8');
33+
}
34+
const config = SSHConfig.parse(content);
35+
36+
if (await fileExists(systemSSHConfig)) {
37+
content = await fs.promises.readFile(systemSSHConfig, 'utf8');
38+
config.push(...SSHConfig.parse(content));
39+
}
40+
41+
return new SSHConfiguration(config);
42+
}
43+
44+
constructor(private sshConfig: SSHConfig) {
45+
}
46+
47+
getAllConfiguredHosts(): string[] {
48+
const hosts = new Set<string>();
49+
this.sshConfig
50+
.filter(isHostSection)
51+
.forEach(hostSection => {
52+
const value = Array.isArray(hostSection.value as string[] | string) ? hostSection.value[0] : hostSection.value;
53+
const hasHostName = hostSection.config.find(line => line.type === SSHConfig.DIRECTIVE && line.param === 'HostName' && !!line.value);
54+
const isPattern = /^!/.test(value) || /[?*]/.test(value);
55+
if (hasHostName && !isPattern) {
56+
hosts.add(value);
57+
}
58+
});
59+
60+
return [...hosts.keys()];
61+
}
62+
63+
getHostConfiguration(host: string): Record<string, string> {
64+
return this.sshConfig.compute(host);
65+
}
66+
67+
getHostNameAlias(hostname: string): string | undefined {
68+
const found = this.sshConfig
69+
.filter(isHostSection)
70+
.find(hostSection => hostSection.config.find(line => line.type === SSHConfig.DIRECTIVE && line.param === 'HostName' && line.value === hostname));
71+
72+
return found?.value;
73+
}
74+
}

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2434,6 +2434,11 @@ sprintf-js@~1.0.2:
24342434
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
24352435
integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==
24362436

2437+
ssh-config@^4.1.6:
2438+
version "4.1.6"
2439+
resolved "https://registry.yarnpkg.com/ssh-config/-/ssh-config-4.1.6.tgz#008eee24f5e5029dc64d50de4a5a7a12342db8b1"
2440+
integrity sha512-YdPYn/2afoBonSFoMSvC1FraA/LKKrvy8UvbvAFGJ8gdlKuANvufLLkf8ynF2uq7Tl5+DQBIFyN37//09nAgNQ==
2441+
24372442
ssh2@^1.10.0:
24382443
version "1.10.0"
24392444
resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.10.0.tgz#e05d870dfc8e83bc918a2ffb3dcbd4d523472dee"

0 commit comments

Comments
 (0)