Skip to content

Commit 1017632

Browse files
authored
refactor(ec2): enforce ssh key inside global storage. (#5814)
## Problem Followup from #5642 (comment) There is a case where we could be overwriting keys/files in the `.ssh/` directory if not careful. ## Solution Don't allows keys to generated in a directory we do not control, and therefore avoid possibility of overwriting user ssh keys. --- <!--- 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.
1 parent 566f83e commit 1017632

File tree

6 files changed

+58
-56
lines changed

6 files changed

+58
-56
lines changed

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55
import * as vscode from 'vscode'
6-
import * as path from 'path'
76
import { Session } from 'aws-sdk/clients/ssm'
87
import { IAM, SSM } from 'aws-sdk'
98
import { Ec2Selection } from './prompter'
@@ -28,7 +27,6 @@ import { CancellationError, Timeout } from '../../shared/utilities/timeoutUtils'
2827
import { showMessageWithCancel } from '../../shared/utilities/messages'
2928
import { SshConfig, sshLogFileLocation } from '../../shared/sshConfig'
3029
import { SshKeyPair } from './sshKeyPair'
31-
import globals from '../../shared/extensionGlobals'
3230

3331
export type Ec2ConnectErrorCode = 'EC2SSMStatus' | 'EC2SSMPermission' | 'EC2SSMConnect' | 'EC2SSMAgentStatus'
3432

@@ -235,8 +233,7 @@ export class Ec2ConnectionManager {
235233
}
236234

237235
public async configureSshKeys(selection: Ec2Selection, remoteUser: string): Promise<SshKeyPair> {
238-
const keyPath = path.join(globals.context.globalStorageUri.fsPath, `aws-ec2-key`)
239-
const keyPair = await SshKeyPair.getSshKeyPair(keyPath, 30000)
236+
const keyPair = await SshKeyPair.getSshKeyPair(`aws-ec2-key`, 30000)
240237
await this.sendSshKeyToInstance(selection, keyPair, remoteUser)
241238
return keyPair
242239
}

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { tryRun } from '../../shared/utilities/pathFind'
99
import { Timeout } from '../../shared/utilities/timeoutUtils'
1010
import { findAsync } from '../../shared/utilities/collectionUtils'
1111
import { RunParameterContext } from '../../shared/utilities/processUtils'
12+
import path from 'path'
1213

1314
type sshKeyType = 'rsa' | 'ed25519'
1415

@@ -20,19 +21,35 @@ export class SshKeyPair {
2021
private readonly keyPath: string,
2122
lifetime: number
2223
) {
23-
this.publicKeyPath = `${keyPath}.pub`
24+
this.publicKeyPath = `${this.keyPath}.pub`
2425
this.lifeTimeout = new Timeout(lifetime)
2526

2627
this.lifeTimeout.onCompletion(async () => {
2728
await this.delete()
2829
})
2930
}
3031

31-
public static async getSshKeyPair(keyPath: string, lifetime: number) {
32+
private static getKeypath(keyName: string): string {
33+
return path.join(globals.context.globalStorageUri.fsPath, keyName)
34+
}
35+
36+
public static async getSshKeyPair(keyName: string, lifetime: number) {
37+
const keyPath = SshKeyPair.getKeypath(keyName)
3238
await SshKeyPair.generateSshKeyPair(keyPath)
3339
return new SshKeyPair(keyPath, lifetime)
3440
}
3541

42+
private static isValidKeyPath(keyPath: string): boolean {
43+
const relative = path.relative(globals.context.globalStorageUri.fsPath, keyPath)
44+
return relative !== undefined && !relative.startsWith('..') && !path.isAbsolute(relative) && keyPath.length > 4
45+
}
46+
47+
private static assertValidKeypath(keyPath: string, message: string): void | never {
48+
if (!SshKeyPair.isValidKeyPath(keyPath)) {
49+
throw new ToolkitError(message)
50+
}
51+
}
52+
3653
private static async assertGenerated(keyPath: string, keyGenerated: boolean): Promise<never | void> {
3754
if (!keyGenerated) {
3855
throw new ToolkitError('ec2: Unable to generate ssh key pair with either ed25519 or rsa')
@@ -85,6 +102,10 @@ export class SshKeyPair {
85102
}
86103

87104
public async delete(): Promise<void> {
105+
SshKeyPair.assertValidKeypath(
106+
this.keyPath,
107+
`ec2: keyPath became invalid after creation, not deleting key at ${this.keyPath}`
108+
)
88109
await fs.delete(this.keyPath, { force: true })
89110
await fs.delete(this.publicKeyPath, { force: true })
90111

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

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import { SshKeyPair } from '../../../awsService/ec2/sshKeyPair'
1515
import { DefaultIamClient } from '../../../shared/clients/iamClient'
1616
import { assertNoTelemetryMatch, createTestWorkspaceFolder } from '../../testUtil'
1717
import { fs } from '../../../shared'
18-
import path from 'path'
1918

2019
describe('Ec2ConnectClient', function () {
2120
let client: Ec2ConnectionManager
@@ -135,15 +134,13 @@ describe('Ec2ConnectClient', function () {
135134
it('calls the sdk with the proper parameters', async function () {
136135
const sendCommandStub = sinon.stub(SsmClient.prototype, 'sendCommandAndWait')
137136

138-
sinon.stub(SshKeyPair, 'generateSshKeyPair')
139-
sinon.stub(SshKeyPair.prototype, 'getPublicKey').resolves('test-key')
140-
141137
const testSelection = {
142138
instanceId: 'test-id',
143139
region: 'test-region',
144140
}
145-
const mockKeys = await SshKeyPair.getSshKeyPair('fakeDir', 30000)
146-
await client.sendSshKeyToInstance(testSelection, mockKeys, 'test-user')
141+
142+
const keys = await SshKeyPair.getSshKeyPair('key', 30000)
143+
await client.sendSshKeyToInstance(testSelection, keys, 'test-user')
147144
sinon.assert.calledWith(sendCommandStub, testSelection.instanceId, 'AWS-RunShellScript')
148145
sinon.restore()
149146
})
@@ -156,10 +153,9 @@ describe('Ec2ConnectClient', function () {
156153
region: 'test-region',
157154
}
158155
const testWorkspaceFolder = await createTestWorkspaceFolder()
159-
const keyPath = path.join(testWorkspaceFolder.uri.fsPath, 'key')
160-
const keys = await SshKeyPair.getSshKeyPair(keyPath, 60000)
156+
const keys = await SshKeyPair.getSshKeyPair('key', 60000)
161157
await client.sendSshKeyToInstance(testSelection, keys, 'test-user')
162-
const privKey = await fs.readFileText(keyPath)
158+
const privKey = await fs.readFileText(keys.getPrivateKeyPath())
163159
assertNoTelemetryMatch(privKey)
164160
sinon.restore()
165161

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

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,50 +5,47 @@
55
import assert from 'assert'
66
import nodefs from 'fs' // eslint-disable-line no-restricted-imports
77
import * as sinon from 'sinon'
8-
import * as path from 'path'
98
import * as os from 'os'
109
import { SshKeyPair } from '../../../awsService/ec2/sshKeyPair'
11-
import { createTestWorkspaceFolder, installFakeClock } from '../../testUtil'
10+
import { installFakeClock } from '../../testUtil'
1211
import { InstalledClock } from '@sinonjs/fake-timers'
1312
import { ChildProcess } from '../../../shared/utilities/processUtils'
1413
import { fs, globals } from '../../../shared'
1514

1615
describe('SshKeyPair', async function () {
17-
let temporaryDirectory: string
18-
let keyPath: string
19-
let keyPair: SshKeyPair
2016
let clock: InstalledClock
17+
let keyPair: SshKeyPair
18+
let keyName: string
2119

2220
before(async function () {
23-
temporaryDirectory = (await createTestWorkspaceFolder()).uri.fsPath
24-
keyPath = path.join(temporaryDirectory, 'testKeyPair')
2521
clock = installFakeClock()
2622
})
2723

2824
beforeEach(async function () {
29-
keyPair = await SshKeyPair.getSshKeyPair(keyPath, 30000)
25+
keyName = 'testKeyPair'
26+
keyPair = await SshKeyPair.getSshKeyPair(keyName, 30000)
3027
})
3128

3229
afterEach(async function () {
3330
await keyPair.delete()
3431
})
3532

3633
after(async function () {
37-
await fs.delete(temporaryDirectory, { recursive: true })
3834
clock.uninstall()
3935
sinon.restore()
4036
})
4137

4238
it('generates key in target file', async function () {
43-
const contents = await fs.readFileBytes(keyPath)
39+
const contents = await fs.readFileBytes(keyPair.getPrivateKeyPath())
4440
assert.notStrictEqual(contents.length, 0)
4541
})
4642

4743
it('generates unique key each time', async function () {
48-
const beforeContent = await fs.readFileBytes(keyPath)
49-
keyPair = await SshKeyPair.getSshKeyPair(keyPath, 30000)
50-
const afterContent = await fs.readFileBytes(keyPath)
51-
assert.notStrictEqual(beforeContent, afterContent)
44+
const keyPair2 = await SshKeyPair.getSshKeyPair(`${keyName}2`, 30000)
45+
const content1 = await fs.readFileBytes(keyPair2.getPrivateKeyPath())
46+
const content2 = await fs.readFileBytes(keyPair.getPrivateKeyPath())
47+
assert.notStrictEqual(content1, content2)
48+
await keyPair2.delete()
5249
})
5350

5451
it('sets permission of the file to read/write owner', async function () {
@@ -59,7 +56,7 @@ describe('SshKeyPair', async function () {
5956
})
6057

6158
it('defaults to ed25519 key type', async function () {
62-
const process = new ChildProcess(`ssh-keygen`, ['-vvv', '-l', '-f', keyPath])
59+
const process = new ChildProcess(`ssh-keygen`, ['-vvv', '-l', '-f', keyPair.getPrivateKeyPath()])
6360
const result = await process.run()
6461
// Check private key header for algorithm name
6562
assert.strictEqual(result.stdout.includes('[ED25519 256]'), true)
@@ -70,29 +67,25 @@ describe('SshKeyPair', async function () {
7067
const stub = sinon.stub(SshKeyPair, 'tryKeyGen')
7168
stub.onFirstCall().resolves(false)
7269
stub.callThrough()
73-
keyPair = await SshKeyPair.getSshKeyPair(keyPath, 30000)
74-
const process = new ChildProcess(`ssh-keygen`, ['-vvv', '-l', '-f', keyPath])
70+
const rsaKey = await SshKeyPair.getSshKeyPair('rsa', 30000)
71+
const process = new ChildProcess(`ssh-keygen`, ['-vvv', '-l', '-f', rsaKey.getPrivateKeyPath()])
7572
const result = await process.run()
7673
// Check private key header for algorithm name
7774
assert.strictEqual(result.stdout.includes('[RSA'), true)
7875
stub.restore()
7976
})
8077

81-
it('properly names the public key', function () {
82-
assert.strictEqual(keyPair.getPublicKeyPath(), `${keyPath}.pub`)
83-
})
84-
8578
it('reads in public ssh key that is non-empty', async function () {
8679
const key = await keyPair.getPublicKey()
8780
assert.notStrictEqual(key.length, 0)
8881
})
8982

9083
it('does overwrite existing keys on get call', async function () {
9184
const generateStub = sinon.spy(SshKeyPair, 'generateSshKeyPair')
92-
const keyBefore = await fs.readFileBytes(keyPath)
93-
keyPair = await SshKeyPair.getSshKeyPair(keyPath, 30000)
85+
const keyBefore = await fs.readFileBytes(keyPair.getPrivateKeyPath())
86+
keyPair = await SshKeyPair.getSshKeyPair(keyName, 30000)
9487

95-
const keyAfter = await fs.readFileBytes(keyPath)
88+
const keyAfter = await fs.readFileBytes(keyPair.getPrivateKeyPath())
9689
sinon.assert.calledOnce(generateStub)
9790

9891
assert.notStrictEqual(keyBefore, keyAfter)
@@ -118,7 +111,7 @@ describe('SshKeyPair', async function () {
118111
sinon.stub(SshKeyPair, 'generateSshKeyPair')
119112
const deleteStub = sinon.stub(SshKeyPair.prototype, 'delete')
120113

121-
keyPair = await SshKeyPair.getSshKeyPair(keyPath, 50)
114+
keyPair = await SshKeyPair.getSshKeyPair(keyName, 50)
122115
await clock.tickAsync(10)
123116
sinon.assert.notCalled(deleteStub)
124117
await clock.tickAsync(100)

packages/core/src/test/globalSetup.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,13 @@ export const mochaHooks = {
101101
globals.telemetry.clearRecords()
102102
globals.telemetry.logger.clear()
103103
TelemetryDebounceInfo.instance.clear()
104+
104105
// mochaGlobalSetup() set this to a fake, so it's safe to clear it here.
105106
await globals.globalState.clear()
106107

107108
await testUtil.closeAllEditors()
109+
await fs.delete(globals.context.globalStorageUri.fsPath, { recursive: true, force: true })
110+
await fs.mkdir(globals.context.globalStorageUri.fsPath)
108111
},
109112
async afterEach(this: Mocha.Context) {
110113
if (openExternalStub.called && openExternalStub.returned(sinon.match.typeOf('undefined'))) {

packages/core/src/test/shared/sshConfig.test.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,11 @@
55
import assert from 'assert'
66
import * as sinon from 'sinon'
77
import * as path from 'path'
8-
import * as vscode from 'vscode'
98
import * as http from 'http'
109
import { ToolkitError } from '../../shared/errors'
1110
import { Result } from '../../shared/utilities/result'
1211
import { ChildProcess, ChildProcessResult } from '../../shared/utilities/processUtils'
1312
import { SshConfig, ensureConnectScript, sshLogFileLocation } from '../../shared/sshConfig'
14-
import { FakeExtensionContext } from '../fakeExtensionContext'
1513
import { fileExists, makeTemporaryToolkitFolder } from '../../shared/filesystemUtilities'
1614
import {
1715
DevEnvironmentId,
@@ -20,8 +18,9 @@ import {
2018
getCodeCatalystSsmEnv,
2119
} from '../../codecatalyst/model'
2220
import { StartDevEnvironmentSessionRequest } from 'aws-sdk/clients/codecatalyst'
23-
import { mkdir, readFile, writeFile } from 'fs/promises'
21+
import { mkdir, readFile } from 'fs/promises'
2422
import fs from '../../shared/fs/fs'
23+
import { globals } from '../../shared'
2524

2625
class MockSshConfig extends SshConfig {
2726
// State variables to track logic flow.
@@ -181,22 +180,15 @@ describe('VscodeRemoteSshConfig', async function () {
181180
})
182181

183182
describe('CodeCatalyst Connect Script', function () {
184-
let context: FakeExtensionContext
185-
186183
function isWithin(path1: string, path2: string): boolean {
187184
const rel = path.relative(path1, path2)
188185
return !path.isAbsolute(rel) && !rel.startsWith('..') && !!rel
189186
}
190187

191-
beforeEach(async function () {
192-
context = await FakeExtensionContext.create()
193-
context.globalStorageUri = vscode.Uri.file(await makeTemporaryToolkitFolder())
194-
})
195-
196188
it('can get a connect script path, adding a copy to global storage', async function () {
197-
const script = (await ensureConnectScript(connectScriptPrefix, context)).unwrap().fsPath
189+
const script = (await ensureConnectScript(connectScriptPrefix, globals.context)).unwrap().fsPath
198190
assert.ok(await fileExists(script))
199-
assert.ok(isWithin(context.globalStorageUri.fsPath, script))
191+
assert.ok(isWithin(globals.context.globalStorageUri.fsPath, script))
200192
})
201193

202194
function createFakeServer(testDevEnv: DevEnvironmentId) {
@@ -247,8 +239,8 @@ describe('CodeCatalyst Connect Script', function () {
247239
server.listen({ host: 'localhost', port: 28142 }, () => resolve(`http://localhost:28142`))
248240
})
249241

250-
await writeFile(bearerTokenCacheLocation(testDevEnv.id), 'token')
251-
const script = (await ensureConnectScript(connectScriptPrefix, context)).unwrap().fsPath
242+
await fs.writeFile(bearerTokenCacheLocation(testDevEnv.id), 'token')
243+
const script = (await ensureConnectScript(connectScriptPrefix, globals.context)).unwrap().fsPath
252244
const env = getCodeCatalystSsmEnv('us-weast-1', 'echo', testDevEnv)
253245
env.CODECATALYST_ENDPOINT = address
254246

@@ -280,12 +272,12 @@ describe('CodeCatalyst Connect Script', function () {
280272
})
281273

282274
it('works if the .ssh directory is missing', async function () {
283-
;(await ensureConnectScript(connectScriptPrefix, context)).unwrap()
275+
;(await ensureConnectScript(connectScriptPrefix, globals.context)).unwrap()
284276
})
285277

286278
it('works if the .ssh directory exists but has different perms', async function () {
287279
await mkdir(path.join(tmpDir, '.ssh'), 0o777)
288-
;(await ensureConnectScript(connectScriptPrefix, context)).unwrap()
280+
;(await ensureConnectScript(connectScriptPrefix, globals.context)).unwrap()
289281
})
290282
})
291283
})

0 commit comments

Comments
 (0)