Skip to content

Commit 24840fd

Browse files
HweinstockJadenSimonjustinmk3Weinstock
authored
fix(toolkit): attach lifetime to generated ssh keys. (#5578)
## Problem For connecting VSCode to EC2 instance, we generate an ssh key pair on disk. This results in writing the private ssh key to VSCode global storage, allowing the key to be potentially reused by other users on the same machine. ## Solution Attach a lifetime to any key pair generated such that they wipe from disk after X seconds. Value is currently set is 30 seconds to allow connection to reliably establish. Also, change file permissions to read/write owner only and change behavior to overwrite existing keys. Unable to test file permissions, due to VSCode file system unable to provide us with enough detail. Only provides whether it is readonly or not (somewhat unreliably: microsoft/vscode-discussions#673). VSCode file system is what is used in `fs.ts` implementation. This is in-line with how ec2 instance connect works: https://github.com/aws/aws-ec2-instance-connect-cli/blob/master/ec2instanceconnectcli/EC2InstanceConnectKey.py --- <!--- REMINDER: Ensure that your PR meets the guidelines in CONTRIBUTING.md --> License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --------- Co-authored-by: JadenSimon <31319484+JadenSimon@users.noreply.github.com> Co-authored-by: Justin M. Keyes <jmkeyes@amazon.com> Co-authored-by: Weinstock <hkobew@80a9971f0a95.ant.amazon.com>
1 parent 42da514 commit 24840fd

File tree

4 files changed

+118
-26
lines changed

4 files changed

+118
-26
lines changed

packages/core/src/awsService/ec2/model.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type Ec2ConnectErrorCode = 'EC2SSMStatus' | 'EC2SSMPermission' | 'EC2SSMC
3434

3535
interface Ec2RemoteEnv extends VscodeRemoteConnection {
3636
selection: Ec2Selection
37+
keyPair: SshKeyPair
3738
}
3839

3940
export class Ec2ConnectionManager {
@@ -194,9 +195,9 @@ export class Ec2ConnectionManager {
194195
public async prepareEc2RemoteEnv(selection: Ec2Selection, remoteUser: string): Promise<Ec2RemoteEnv> {
195196
const logger = this.configureRemoteConnectionLogger(selection.instanceId)
196197
const { ssm, vsc, ssh } = (await ensureDependencies()).unwrap()
197-
const keyPath = await this.configureSshKeys(selection, remoteUser)
198+
const keyPair = await this.configureSshKeys(selection, remoteUser)
198199
const hostNamePrefix = 'aws-ec2-'
199-
const sshConfig = new SshConfig(ssh, hostNamePrefix, 'ec2_connect', keyPath)
200+
const sshConfig = new SshConfig(ssh, hostNamePrefix, 'ec2_connect', keyPair.getPrivateKeyPath())
200201

201202
const config = await sshConfig.ensureValid()
202203
if (config.isErr()) {
@@ -223,6 +224,7 @@ export class Ec2ConnectionManager {
223224
vscPath: vsc,
224225
SessionProcess,
225226
selection,
227+
keyPair,
226228
}
227229
}
228230

@@ -232,11 +234,11 @@ export class Ec2ConnectionManager {
232234
return logger
233235
}
234236

235-
public async configureSshKeys(selection: Ec2Selection, remoteUser: string): Promise<string> {
237+
public async configureSshKeys(selection: Ec2Selection, remoteUser: string): Promise<SshKeyPair> {
236238
const keyPath = path.join(globals.context.globalStorageUri.fsPath, `aws-ec2-key`)
237-
const keyPair = await SshKeyPair.getSshKeyPair(keyPath)
239+
const keyPair = await SshKeyPair.getSshKeyPair(keyPath, 30000)
238240
await this.sendSshKeyToInstance(selection, keyPair, remoteUser)
239-
return keyPath
241+
return keyPair
240242
}
241243

242244
public async sendSshKeyToInstance(

packages/core/src/awsService/ec2/sshKeyPair.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,36 @@
22
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
5-
import * as fs from 'fs-extra'
5+
import { fs } from '../../shared'
6+
import { chmodSync } from 'fs'
67
import { ToolkitError } from '../../shared/errors'
78
import { ChildProcess } from '../../shared/utilities/childProcess'
9+
import { Timeout } from '../../shared/utilities/timeoutUtils'
810

911
export class SshKeyPair {
1012
private publicKeyPath: string
11-
private constructor(keyPath: string) {
13+
private lifeTimeout: Timeout
14+
private deleted: boolean = false
15+
16+
private constructor(
17+
private keyPath: string,
18+
lifetime: number
19+
) {
1220
this.publicKeyPath = `${keyPath}.pub`
21+
this.lifeTimeout = new Timeout(lifetime)
22+
23+
this.lifeTimeout.onCompletion(async () => {
24+
await this.delete()
25+
})
1326
}
1427

15-
public static async getSshKeyPair(keyPath: string) {
16-
const keyExists = await fs.pathExists(keyPath)
17-
if (!keyExists) {
18-
await SshKeyPair.generateSshKeyPair(keyPath)
28+
public static async getSshKeyPair(keyPath: string, lifetime: number) {
29+
// Overwrite key if already exists
30+
if (await fs.existsFile(keyPath)) {
31+
await fs.delete(keyPath)
1932
}
20-
return new SshKeyPair(keyPath)
33+
await SshKeyPair.generateSshKeyPair(keyPath)
34+
return new SshKeyPair(keyPath, lifetime)
2135
}
2236

2337
public static async generateSshKeyPair(keyPath: string): Promise<void> {
@@ -26,14 +40,38 @@ export class SshKeyPair {
2640
if (result.exitCode !== 0) {
2741
throw new ToolkitError('ec2: Failed to generate ssh key', { details: { stdout: result.stdout } })
2842
}
43+
chmodSync(keyPath, 0o600)
2944
}
3045

3146
public getPublicKeyPath(): string {
3247
return this.publicKeyPath
3348
}
3449

50+
public getPrivateKeyPath(): string {
51+
return this.keyPath
52+
}
53+
3554
public async getPublicKey(): Promise<string> {
36-
const contents = await fs.readFile(this.publicKeyPath, 'utf-8')
55+
const contents = await fs.readFileAsString(this.publicKeyPath)
3756
return contents
3857
}
58+
59+
public async delete(): Promise<void> {
60+
await fs.delete(this.keyPath)
61+
await fs.delete(this.publicKeyPath)
62+
63+
if (!this.lifeTimeout.completed) {
64+
this.lifeTimeout.cancel()
65+
}
66+
67+
this.deleted = true
68+
}
69+
70+
public isDeleted(): boolean {
71+
return this.deleted
72+
}
73+
74+
public timeAlive(): number {
75+
return this.lifeTimeout.elapsedTime
76+
}
3977
}

packages/core/src/test/awsService/ec2/model.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ describe('Ec2ConnectClient', function () {
139139
instanceId: 'test-id',
140140
region: 'test-region',
141141
}
142-
const mockKeys = await SshKeyPair.getSshKeyPair('')
142+
const mockKeys = await SshKeyPair.getSshKeyPair('', 30000)
143143
await client.sendSshKeyToInstance(testSelection, mockKeys, '')
144144
sinon.assert.calledWith(sendCommandStub, testSelection.instanceId, 'AWS-RunShellScript')
145145
sinon.restore()

packages/core/src/test/awsService/ec2/sshKeyPair.test.ts

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,55 @@
22
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
5-
5+
import * as vscode from 'vscode'
66
import assert from 'assert'
7-
import * as fs from 'fs-extra'
87
import * as sinon from 'sinon'
9-
import { makeTemporaryToolkitFolder, tryRemoveFolder } from '../../../shared/filesystemUtilities'
8+
import * as path from 'path'
109
import { SshKeyPair } from '../../../awsService/ec2/sshKeyPair'
10+
import { createTestWorkspaceFolder, installFakeClock } from '../../testUtil'
11+
import { InstalledClock } from '@sinonjs/fake-timers'
1112
import { ChildProcess } from '../../../shared/utilities/childProcess'
13+
import { fs } from '../../../shared'
1214

1315
describe('SshKeyUtility', async function () {
1416
let temporaryDirectory: string
1517
let keyPath: string
1618
let keyPair: SshKeyPair
19+
let clock: InstalledClock
1720

1821
before(async function () {
19-
temporaryDirectory = await makeTemporaryToolkitFolder()
20-
keyPath = `${temporaryDirectory}/test-key`
21-
keyPair = await SshKeyPair.getSshKeyPair(keyPath)
22+
temporaryDirectory = (await createTestWorkspaceFolder()).uri.fsPath
23+
keyPath = path.join(temporaryDirectory, 'testKeyPair')
24+
clock = installFakeClock()
25+
})
26+
27+
beforeEach(async function () {
28+
keyPair = await SshKeyPair.getSshKeyPair(keyPath, 30000)
29+
})
30+
31+
afterEach(async function () {
32+
await keyPair.delete()
2233
})
2334

2435
after(async function () {
25-
await tryRemoveFolder(temporaryDirectory)
36+
await keyPair.delete()
37+
clock.uninstall()
38+
sinon.restore()
2639
})
2740

2841
describe('generateSshKeys', async function () {
2942
it('generates key in target file', async function () {
30-
const contents = await fs.readFile(keyPath, 'utf-8')
43+
const contents = await vscode.workspace.fs.readFile(vscode.Uri.file(keyPath))
3144
assert.notStrictEqual(contents.length, 0)
3245
})
3346

47+
it('generates unique key each time', async function () {
48+
const beforeContent = await vscode.workspace.fs.readFile(vscode.Uri.file(keyPath))
49+
keyPair = await SshKeyPair.getSshKeyPair(keyPath, 30000)
50+
const afterContent = await vscode.workspace.fs.readFile(vscode.Uri.file(keyPath))
51+
assert.notStrictEqual(beforeContent, afterContent)
52+
})
53+
3454
it('uses ed25519 algorithm to generate the keys', async function () {
3555
const process = new ChildProcess(`ssh-keygen`, ['-vvv', '-l', '-f', keyPath])
3656
const result = await process.run()
@@ -48,10 +68,42 @@ describe('SshKeyUtility', async function () {
4868
assert.notStrictEqual(key.length, 0)
4969
})
5070

51-
it('does not overwrite existing keys', async function () {
52-
const generateStub = sinon.stub(SshKeyPair, 'generateSshKeyPair')
53-
await SshKeyPair.getSshKeyPair(keyPath)
54-
sinon.assert.notCalled(generateStub)
71+
it('does overwrite existing keys on get call', async function () {
72+
const generateStub = sinon.spy(SshKeyPair, 'generateSshKeyPair')
73+
const keyBefore = await vscode.workspace.fs.readFile(vscode.Uri.file(keyPath))
74+
keyPair = await SshKeyPair.getSshKeyPair(keyPath, 30000)
75+
76+
const keyAfter = await vscode.workspace.fs.readFile(vscode.Uri.file(keyPath))
77+
sinon.assert.calledOnce(generateStub)
78+
79+
assert.notStrictEqual(keyBefore, keyAfter)
80+
sinon.restore()
81+
})
82+
83+
it('deletes key on delete', async function () {
84+
const pubKeyExistsBefore = await fs.existsFile(keyPair.getPublicKeyPath())
85+
const privateKeyExistsBefore = await fs.existsFile(keyPair.getPrivateKeyPath())
86+
87+
await keyPair.delete()
88+
89+
const pubKeyExistsAfter = await fs.existsFile(keyPair.getPublicKeyPath())
90+
const privateKeyExistsAfter = await fs.existsFile(keyPair.getPrivateKeyPath())
91+
92+
assert.strictEqual(pubKeyExistsBefore && privateKeyExistsBefore, true)
93+
assert.strictEqual(pubKeyExistsAfter && privateKeyExistsAfter, false)
94+
assert(keyPair.isDeleted())
95+
})
96+
97+
it('deletes keys after timeout', async function () {
98+
// Stub methods interacting with file system to avoid flaky test.
99+
sinon.stub(SshKeyPair, 'generateSshKeyPair')
100+
const deleteStub = sinon.stub(SshKeyPair.prototype, 'delete')
101+
102+
keyPair = await SshKeyPair.getSshKeyPair(keyPath, 50)
103+
await clock.tickAsync(10)
104+
sinon.assert.notCalled(deleteStub)
105+
await clock.tickAsync(100)
106+
sinon.assert.calledOnce(deleteStub)
55107
sinon.restore()
56108
})
57109
})

0 commit comments

Comments
 (0)