Skip to content

Commit 29647a0

Browse files
committed
test(ssh): add host key verification tests
1 parent 27314f8 commit 29647a0

File tree

2 files changed

+386
-0
lines changed

2 files changed

+386
-0
lines changed

test/ssh/hostKeyManager.test.ts

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { ensureHostKey, validateHostKeyExists } from '../../src/proxy/ssh/hostKeyManager';
3+
4+
// Mock modules
5+
const { fsStub, childProcessStub } = vi.hoisted(() => {
6+
return {
7+
fsStub: {
8+
existsSync: vi.fn(),
9+
readFileSync: vi.fn(),
10+
mkdirSync: vi.fn(),
11+
accessSync: vi.fn(),
12+
constants: { R_OK: 4 },
13+
},
14+
childProcessStub: {
15+
execSync: vi.fn(),
16+
},
17+
};
18+
});
19+
20+
vi.mock('fs', async () => {
21+
const actual = await vi.importActual<typeof import('fs')>('fs');
22+
return {
23+
...actual,
24+
existsSync: fsStub.existsSync,
25+
readFileSync: fsStub.readFileSync,
26+
mkdirSync: fsStub.mkdirSync,
27+
accessSync: fsStub.accessSync,
28+
constants: fsStub.constants,
29+
default: {
30+
...actual,
31+
existsSync: fsStub.existsSync,
32+
readFileSync: fsStub.readFileSync,
33+
mkdirSync: fsStub.mkdirSync,
34+
accessSync: fsStub.accessSync,
35+
constants: fsStub.constants,
36+
},
37+
};
38+
});
39+
40+
vi.mock('child_process', async () => {
41+
const actual = await vi.importActual<typeof import('child_process')>('child_process');
42+
return {
43+
...actual,
44+
execSync: childProcessStub.execSync,
45+
};
46+
});
47+
48+
describe('hostKeyManager', () => {
49+
beforeEach(() => {
50+
vi.clearAllMocks();
51+
});
52+
53+
afterEach(() => {
54+
vi.restoreAllMocks();
55+
});
56+
57+
describe('ensureHostKey', () => {
58+
it('should return existing host key when it exists', () => {
59+
const privateKeyPath = '/path/to/ssh_host_key';
60+
const publicKeyPath = '/path/to/ssh_host_key.pub';
61+
const mockKeyData = Buffer.from(
62+
'-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----',
63+
);
64+
65+
fsStub.existsSync.mockReturnValue(true);
66+
fsStub.readFileSync.mockReturnValue(mockKeyData);
67+
68+
const result = ensureHostKey({ privateKeyPath, publicKeyPath });
69+
70+
expect(result).toEqual(mockKeyData);
71+
expect(fsStub.existsSync).toHaveBeenCalledWith(privateKeyPath);
72+
expect(fsStub.readFileSync).toHaveBeenCalledWith(privateKeyPath);
73+
expect(childProcessStub.execSync).not.toHaveBeenCalled();
74+
});
75+
76+
it('should throw error when existing key cannot be read', () => {
77+
const privateKeyPath = '/path/to/ssh_host_key';
78+
const publicKeyPath = '/path/to/ssh_host_key.pub';
79+
80+
fsStub.existsSync.mockReturnValue(true);
81+
fsStub.readFileSync.mockImplementation(() => {
82+
throw new Error('Permission denied');
83+
});
84+
85+
expect(() => {
86+
ensureHostKey({ privateKeyPath, publicKeyPath });
87+
}).toThrow('Failed to read existing SSH host key');
88+
});
89+
90+
it('should throw error for invalid private key path with unsafe characters', () => {
91+
const privateKeyPath = '/path/to/key;rm -rf /';
92+
const publicKeyPath = '/path/to/key.pub';
93+
94+
expect(() => {
95+
ensureHostKey({ privateKeyPath, publicKeyPath });
96+
}).toThrow('Invalid SSH host key path');
97+
});
98+
99+
it('should throw error for invalid public key path with unsafe characters', () => {
100+
const privateKeyPath = '/path/to/key';
101+
const publicKeyPath = '/path/to/key.pub && echo hacked';
102+
103+
expect(() => {
104+
ensureHostKey({ privateKeyPath, publicKeyPath });
105+
}).toThrow('Invalid SSH host key path');
106+
});
107+
108+
it('should generate new key when it does not exist', () => {
109+
const privateKeyPath = '/path/to/ssh_host_key';
110+
const publicKeyPath = '/path/to/ssh_host_key.pub';
111+
const mockKeyData = Buffer.from(
112+
'-----BEGIN OPENSSH PRIVATE KEY-----\ngenerated\n-----END OPENSSH PRIVATE KEY-----',
113+
);
114+
115+
fsStub.existsSync
116+
.mockReturnValueOnce(false) // Check if private key exists
117+
.mockReturnValueOnce(false) // Check if directory exists
118+
.mockReturnValueOnce(true); // Verify key was created
119+
120+
fsStub.readFileSync.mockReturnValue(mockKeyData);
121+
childProcessStub.execSync.mockReturnValue('');
122+
123+
const result = ensureHostKey({ privateKeyPath, publicKeyPath });
124+
125+
expect(result).toEqual(mockKeyData);
126+
expect(fsStub.mkdirSync).toHaveBeenCalledWith('/path/to', { recursive: true });
127+
expect(childProcessStub.execSync).toHaveBeenCalledWith(
128+
`ssh-keygen -t ed25519 -f "${privateKeyPath}" -N "" -C "git-proxy-host-key"`,
129+
{
130+
stdio: 'pipe',
131+
timeout: 10000,
132+
},
133+
);
134+
});
135+
136+
it('should not create directory if it already exists when generating key', () => {
137+
const privateKeyPath = '/path/to/ssh_host_key';
138+
const publicKeyPath = '/path/to/ssh_host_key.pub';
139+
const mockKeyData = Buffer.from(
140+
'-----BEGIN OPENSSH PRIVATE KEY-----\ngenerated\n-----END OPENSSH PRIVATE KEY-----',
141+
);
142+
143+
fsStub.existsSync
144+
.mockReturnValueOnce(false) // Check if private key exists
145+
.mockReturnValueOnce(true) // Directory already exists
146+
.mockReturnValueOnce(true); // Verify key was created
147+
148+
fsStub.readFileSync.mockReturnValue(mockKeyData);
149+
childProcessStub.execSync.mockReturnValue('');
150+
151+
ensureHostKey({ privateKeyPath, publicKeyPath });
152+
153+
expect(fsStub.mkdirSync).not.toHaveBeenCalled();
154+
});
155+
156+
it('should throw error when key generation fails', () => {
157+
const privateKeyPath = '/path/to/ssh_host_key';
158+
const publicKeyPath = '/path/to/ssh_host_key.pub';
159+
160+
fsStub.existsSync.mockReturnValueOnce(false).mockReturnValueOnce(false);
161+
162+
childProcessStub.execSync.mockImplementation(() => {
163+
throw new Error('ssh-keygen not found');
164+
});
165+
166+
expect(() => {
167+
ensureHostKey({ privateKeyPath, publicKeyPath });
168+
}).toThrow('Failed to generate SSH host key: ssh-keygen not found');
169+
});
170+
171+
it('should throw error when generated key file is not found after generation', () => {
172+
const privateKeyPath = '/path/to/ssh_host_key';
173+
const publicKeyPath = '/path/to/ssh_host_key.pub';
174+
175+
fsStub.existsSync
176+
.mockReturnValueOnce(false) // Check if private key exists
177+
.mockReturnValueOnce(false) // Check if directory exists
178+
.mockReturnValueOnce(false); // Verify key was created - FAIL
179+
180+
childProcessStub.execSync.mockReturnValue('');
181+
182+
expect(() => {
183+
ensureHostKey({ privateKeyPath, publicKeyPath });
184+
}).toThrow('Key generation appeared to succeed but private key file not found');
185+
});
186+
});
187+
188+
describe('validateHostKeyExists', () => {
189+
it('should return true when key exists and is readable', () => {
190+
fsStub.accessSync.mockImplementation(() => {
191+
// No error thrown means success
192+
});
193+
194+
const result = validateHostKeyExists('/path/to/key');
195+
196+
expect(result).toBe(true);
197+
expect(fsStub.accessSync).toHaveBeenCalledWith('/path/to/key', 4);
198+
});
199+
200+
it('should return false when key does not exist', () => {
201+
fsStub.accessSync.mockImplementation(() => {
202+
throw new Error('ENOENT: no such file or directory');
203+
});
204+
205+
const result = validateHostKeyExists('/path/to/key');
206+
207+
expect(result).toBe(false);
208+
});
209+
210+
it('should return false when key is not readable', () => {
211+
fsStub.accessSync.mockImplementation(() => {
212+
throw new Error('EACCES: permission denied');
213+
});
214+
215+
const result = validateHostKeyExists('/path/to/key');
216+
217+
expect(result).toBe(false);
218+
});
219+
});
220+
});

test/ssh/knownHosts.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import {
3+
DEFAULT_KNOWN_HOSTS,
4+
getKnownHosts,
5+
verifyHostKey,
6+
KnownHostsConfig,
7+
} from '../../src/proxy/ssh/knownHosts';
8+
9+
describe('knownHosts', () => {
10+
let consoleErrorSpy: any;
11+
12+
beforeEach(() => {
13+
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
14+
});
15+
16+
afterEach(() => {
17+
consoleErrorSpy.mockRestore();
18+
});
19+
20+
describe('DEFAULT_KNOWN_HOSTS', () => {
21+
it('should contain GitHub host key', () => {
22+
expect(DEFAULT_KNOWN_HOSTS['github.com']).toBeDefined();
23+
expect(DEFAULT_KNOWN_HOSTS['github.com']).toContain('SHA256:');
24+
});
25+
26+
it('should contain GitLab host key', () => {
27+
expect(DEFAULT_KNOWN_HOSTS['gitlab.com']).toBeDefined();
28+
expect(DEFAULT_KNOWN_HOSTS['gitlab.com']).toContain('SHA256:');
29+
});
30+
});
31+
32+
describe('getKnownHosts', () => {
33+
it('should return default hosts when no custom hosts provided', () => {
34+
const result = getKnownHosts();
35+
36+
expect(result['github.com']).toBe(DEFAULT_KNOWN_HOSTS['github.com']);
37+
expect(result['gitlab.com']).toBe(DEFAULT_KNOWN_HOSTS['gitlab.com']);
38+
});
39+
40+
it('should merge custom hosts with defaults', () => {
41+
const customHosts: KnownHostsConfig = {
42+
'custom.example.com': 'SHA256:customfingerprint',
43+
};
44+
45+
const result = getKnownHosts(customHosts);
46+
47+
expect(result['github.com']).toBe(DEFAULT_KNOWN_HOSTS['github.com']);
48+
expect(result['gitlab.com']).toBe(DEFAULT_KNOWN_HOSTS['gitlab.com']);
49+
expect(result['custom.example.com']).toBe('SHA256:customfingerprint');
50+
});
51+
52+
it('should allow custom hosts to override defaults', () => {
53+
const customHosts: KnownHostsConfig = {
54+
'github.com': 'SHA256:overriddenfingerprint',
55+
};
56+
57+
const result = getKnownHosts(customHosts);
58+
59+
expect(result['github.com']).toBe('SHA256:overriddenfingerprint');
60+
expect(result['gitlab.com']).toBe(DEFAULT_KNOWN_HOSTS['gitlab.com']);
61+
});
62+
63+
it('should handle undefined custom hosts', () => {
64+
const result = getKnownHosts(undefined);
65+
66+
expect(result['github.com']).toBe(DEFAULT_KNOWN_HOSTS['github.com']);
67+
});
68+
});
69+
70+
describe('verifyHostKey', () => {
71+
it('should return true for valid GitHub host key', () => {
72+
const knownHosts = getKnownHosts();
73+
const githubKey = DEFAULT_KNOWN_HOSTS['github.com'];
74+
75+
const result = verifyHostKey('github.com', githubKey, knownHosts);
76+
77+
expect(result).toBe(true);
78+
expect(consoleErrorSpy).not.toHaveBeenCalled();
79+
});
80+
81+
it('should return true for valid GitLab host key', () => {
82+
const knownHosts = getKnownHosts();
83+
const gitlabKey = DEFAULT_KNOWN_HOSTS['gitlab.com'];
84+
85+
const result = verifyHostKey('gitlab.com', gitlabKey, knownHosts);
86+
87+
expect(result).toBe(true);
88+
expect(consoleErrorSpy).not.toHaveBeenCalled();
89+
});
90+
91+
it('should return false for unknown hostname', () => {
92+
const knownHosts = getKnownHosts();
93+
94+
const result = verifyHostKey('unknown.host.com', 'SHA256:anything', knownHosts);
95+
96+
expect(result).toBe(false);
97+
expect(consoleErrorSpy).toHaveBeenCalledWith(
98+
expect.stringContaining('Host key verification failed: Unknown host'),
99+
);
100+
expect(consoleErrorSpy).toHaveBeenCalledWith(
101+
expect.stringContaining('Add the host key to your configuration:'),
102+
);
103+
expect(consoleErrorSpy).toHaveBeenCalledWith(
104+
expect.stringContaining('"ssh": { "knownHosts": { "unknown.host.com": "SHA256:..." } }'),
105+
);
106+
});
107+
108+
it('should return false for mismatched fingerprint', () => {
109+
const knownHosts = getKnownHosts();
110+
const wrongFingerprint = 'SHA256:wrongfingerprint';
111+
112+
const result = verifyHostKey('github.com', wrongFingerprint, knownHosts);
113+
114+
expect(result).toBe(false);
115+
expect(consoleErrorSpy).toHaveBeenCalledWith(
116+
expect.stringContaining('Host key verification failed for'),
117+
);
118+
expect(consoleErrorSpy).toHaveBeenCalledWith(
119+
expect.stringContaining(`Expected: ${DEFAULT_KNOWN_HOSTS['github.com']}`),
120+
);
121+
expect(consoleErrorSpy).toHaveBeenCalledWith(
122+
expect.stringContaining(`Received: ${wrongFingerprint}`),
123+
);
124+
expect(consoleErrorSpy).toHaveBeenCalledWith(
125+
expect.stringContaining('WARNING: This could indicate a man-in-the-middle attack!'),
126+
);
127+
});
128+
129+
it('should verify custom host keys', () => {
130+
const customHosts: KnownHostsConfig = {
131+
'custom.example.com': 'SHA256:customfingerprint123',
132+
};
133+
const knownHosts = getKnownHosts(customHosts);
134+
135+
const result = verifyHostKey('custom.example.com', 'SHA256:customfingerprint123', knownHosts);
136+
137+
expect(result).toBe(true);
138+
expect(consoleErrorSpy).not.toHaveBeenCalled();
139+
});
140+
141+
it('should reject custom host with wrong fingerprint', () => {
142+
const customHosts: KnownHostsConfig = {
143+
'custom.example.com': 'SHA256:customfingerprint123',
144+
};
145+
const knownHosts = getKnownHosts(customHosts);
146+
147+
const result = verifyHostKey('custom.example.com', 'SHA256:wrongfingerprint', knownHosts);
148+
149+
expect(result).toBe(false);
150+
expect(consoleErrorSpy).toHaveBeenCalledWith(
151+
expect.stringContaining('Host key verification failed for'),
152+
);
153+
});
154+
155+
it('should handle empty known hosts object', () => {
156+
const emptyHosts: KnownHostsConfig = {};
157+
158+
const result = verifyHostKey('github.com', 'SHA256:anything', emptyHosts);
159+
160+
expect(result).toBe(false);
161+
expect(consoleErrorSpy).toHaveBeenCalledWith(
162+
expect.stringContaining('Host key verification failed: Unknown host'),
163+
);
164+
});
165+
});
166+
});

0 commit comments

Comments
 (0)