CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix(ssh): generate a host key ssh2 can actually parse; self-heal the bad one #5516

Merged⚡ AI-generatedXSccantynz wants to mergefix/ssh-hostkey-formatmainopened 10d ago
2 changed files+71−5
Modifiedsrc/__tests__/ssh-server.test.ts+33−0View fileUnifiedSplit
265265 }
266266 });
267267
268 test("the generated key is parseable by ssh2 itself (the 2026-08-22 outage class)", async () => {
269 // @ts-expect-error no types
270 const { utils } = await import("ssh2");
271 const dir = mkdtempSync(join(tmpdir(), "ssh-hostkey-"));
272 const file = join(dir, "host_key");
273 try {
274 const key = loadOrGenerateHostKey({ envKey: "", keyFilePath: file });
275 const parsed = utils.parseKey(key.toString());
276 expect(parsed instanceof Error).toBe(false);
277 } finally {
278 rmSync(dir, { recursive: true, force: true });
279 }
280 });
281
282 test("a persisted legacy PKCS#8 key (PEM-shaped but ssh2-unparseable) is regenerated", async () => {
283 const { generateKeyPairSync } = await import("crypto");
284 const dir = mkdtempSync(join(tmpdir(), "ssh-hostkey-"));
285 const file = join(dir, "host_key");
286 try {
287 // Exactly what the first enablement persisted: PKCS#8 ed25519 PEM.
288 const { privateKey } = generateKeyPairSync("ed25519", {
289 privateKeyEncoding: { type: "pkcs8", format: "pem" },
290 });
291 writeFs(file, privateKey);
292 const key = loadOrGenerateHostKey({ envKey: "", keyFilePath: file });
293 expect(key.toString()).not.toBe(privateKey);
294 expect(key.toString()).toContain("RSA PRIVATE KEY");
295 expect(readFs(file, "utf8")).toContain("RSA PRIVATE KEY");
296 } finally {
297 rmSync(dir, { recursive: true, force: true });
298 }
299 });
300
268301 test("a corrupt key file is regenerated, not served", () => {
269302 const dir = mkdtempSync(join(tmpdir(), "ssh-hostkey-"));
270303 const file = join(dir, "host_ed25519_key");
Modifiedsrc/lib/ssh-server.ts+38−5View fileUnifiedSplit
6464// Host key
6565// ---------------------------------------------------------------------------
6666
67/** True when ssh2 itself can parse this key — the only opinion that counts. */
68function ssh2CanParse(pem: string): boolean {
69 try {
70 const parsed = sshUtils.parseKey(pem);
71 return !(parsed instanceof Error);
72 } catch {
73 return false;
74 }
75}
76
6777export function loadOrGenerateHostKey(
6878 opts: { envKey?: string; keyFilePath?: string } = {}
6979): Buffer {
7080 const raw = opts.envKey ?? config.sshHostKey;
7181 if (raw) {
7282 // Support \\n escapes (common in env-var values from .env files)
73 return Buffer.from(raw.replace(/\\n/g, "\n"), "utf8");
83 const key = raw.replace(/\\n/g, "\n");
84 // Operator-supplied keys are used as given (failing loudly at server
85 // start beats silently substituting a different host identity) — but
86 // name the problem here, where the cause is visible.
87 if (!ssh2CanParse(key)) {
88 console.warn(
89 "[ssh] SSH_HOST_KEY is set but ssh2 cannot parse it — the server " +
90 "will fail to start. ssh2 accepts OpenSSH-format and classic " +
91 "PKCS#1 RSA PEM keys; PKCS#8 ('BEGIN PRIVATE KEY') is NOT supported."
92 );
93 }
94 return Buffer.from(key, "utf8");
7495 }
7596
7697 // Persistent key file (default: under GIT_REPOS_PATH, i.e. on the
79100 // clone with "REMOTE HOST IDENTIFICATION HAS CHANGED", which is
80101 // indistinguishable from a MITM to the user and would train them to
81102 // ignore the one warning that matters. Generate once, persist, reuse.
103 //
104 // Validation is by ssh2's OWN parser, not a PEM-shaped sniff: the first
105 // enablement (2026-08-22) generated PKCS#8 ed25519 — which Node emits
106 // happily and ssh2 cannot read — persisted it, and then reloaded the
107 // unusable key on every boot ("Cannot parse privateKey: Unsupported key
108 // format"). A key file that ssh2 rejects is regenerated in place.
82109 const keyFilePath = opts.keyFilePath ?? config.sshHostKeyFile;
83110 try {
84111 if (keyFilePath && existsSync(keyFilePath)) {
85112 const fromDisk = readFileSync(keyFilePath, "utf8");
86 if (fromDisk.includes("PRIVATE KEY")) return Buffer.from(fromDisk, "utf8");
113 if (ssh2CanParse(fromDisk)) return Buffer.from(fromDisk, "utf8");
87114 console.warn(
88 `[ssh] ${keyFilePath} exists but does not look like a PEM private key — regenerating`
115 `[ssh] ${keyFilePath} exists but ssh2 cannot parse it (legacy PKCS#8 from the first enablement?) — regenerating`
89116 );
90117 }
91118 } catch (err) {
95122 );
96123 }
97124
98 const { privateKey } = generateKeyPairSync("ed25519", {
99 privateKeyEncoding: { type: "pkcs8", format: "pem" },
125 // RSA in classic PKCS#1 PEM — deliberately NOT ed25519: Node can only
126 // emit ed25519 as PKCS#8/OpenSSL formats, none of which ssh2 parses.
127 // Operators who want an ed25519 host key generate one with ssh-keygen
128 // (OpenSSH format, which ssh2 does parse) and set SSH_HOST_KEY.
129 const { privateKey } = generateKeyPairSync("rsa", {
130 modulusLength: 3072,
131 privateKeyEncoding: { type: "pkcs1", format: "pem" },
132 publicKeyEncoding: { type: "pkcs1", format: "pem" },
100133 });
101134 try {
102135 if (keyFilePath) {
103136
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts