diff --git a/CHANGELOG.md b/CHANGELOG.md index 08719c77b..f093366b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ - A plugin can carry SSH connections over its own stream, and core relays a host's connection over a socket for another Termix - Plugins can create a folder with a color and icon and remove it when it is empty - Another Termix server can manage this one with an API key, which can now ask for a one minute ticket to open plugin sockets as its user +- FIDO2 security keys (ed25519-sk and ecdsa-sk) work through SSH Agent authentication when Termix runs in the desktop app. The key asks for a touch or PIN on your own machine +- Pasting a security key file now explains how to use it instead of failing to decode it ### Changed diff --git a/package.json b/package.json index 06afe4542..84733561a 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "scripts": { "format": "prettier --write .", "format:check": "prettier --check .", - "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs", + "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/patch-ssh2-sk.cjs", "prepare": "husky || true", "prebuild": "node scripts/write-electron-build-info.cjs", "lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs", diff --git a/scripts/patch-ssh2-sk.cjs b/scripts/patch-ssh2-sk.cjs new file mode 100644 index 000000000..4fa99d279 --- /dev/null +++ b/scripts/patch-ssh2-sk.cjs @@ -0,0 +1,149 @@ +const fs = require("node:fs"); + +const SK_ED = "sk-ssh-ed25519@openssh.com"; +const SK_EC = "sk-ecdsa-sha2-nistp256@openssh.com"; + +function replaceOnce(source, from, to, label) { + const first = source.indexOf(from); + if (first === -1 || source.indexOf(from, first + 1) !== -1) { + throw new Error(`ssh2 changed (${label}); review the security key patch`); + } + return source.replace(from, () => to); +} + +function patchKeyParserSource(source) { + if (source.includes("// termix-sk")) return source; + let out = replaceOnce( + source, + ` case 'ecdsa-sha2-nistp521': + return true; + case 'ssh-ed25519': + if (eddsaSupported) + return true;`, + ` case 'ecdsa-sha2-nistp521': + return true; + case '${SK_EC}': // termix-sk + return true; + case '${SK_ED}': + case 'ssh-ed25519': + if (eddsaSupported) + return true;`, + "key types", + ); + out = replaceOnce( + out, + ` default: + return new Error(\`Unsupported OpenSSH public key type: \${baseType}\`); + } + + return new OpenSSH_Public(`, + ` case '${SK_ED}': + case '${SK_EC}': { + // Public blob only; the security key signs, so there is no private half. + const rest = data.subarray(data._pos || 0); + const typeLen = Buffer.byteLength(baseType); + pubSSH = Buffer.allocUnsafe(4 + typeLen + rest.length); + writeUInt32BE(pubSSH, typeLen, 0); + pubSSH.utf8Write(baseType, 4, typeLen); + pubSSH.set(rest, 4 + typeLen); + algo = baseType === '${SK_ED}' ? null : 'sha256'; + break; + } + default: + return new Error(\`Unsupported OpenSSH public key type: \${baseType}\`); + } + + return new OpenSSH_Public(`, + "parseDER", + ); + return out; +} + +function patchAgentSource(source) { + if (source.includes("skTrailer")) return source; + return replaceOnce( + source, + ` binaryParser.init(signature, 0); + binaryParser.readString(true); + signature = binaryParser.readString(); + binaryParser.clear();`, + ` binaryParser.init(signature, 0); + binaryParser.readString(true); + signature = binaryParser.readString(); + // Security key signatures end with flags and a counter. + const skTrailer = signature === undefined + ? undefined + : binaryParser.readRaw(); + binaryParser.clear(); + if (skTrailer && skTrailer.length) + signature.skTrailer = Buffer.from(skTrailer);`, + "agent sign response", + ); +} + +function patchProtocolSource(source) { + if (source.includes("skTrailer")) return source; + const start = source.indexOf(" authPK(username, pubKey, keyAlgo, cbSign) {"); + const end = source.indexOf(" authHostbased(", start); + if (start === -1 || end === -1) { + throw new Error("ssh2 changed (authPK); review the security key patch"); + } + let region = source.slice(start, end); + region = replaceOnce( + region, + ` signature = convertSignature(signature, keyType);`, + ` const skTrailer = signature && signature.skTrailer; + signature = convertSignature(signature, keyType);`, + "authPK sign", + ); + region = replaceOnce( + region, + ` + 4 + algoLen + 4 + sigLen + );`, + ` + 4 + algoLen + 4 + sigLen + (skTrailer ? skTrailer.length : 0) + );`, + "authPK alloc", + ); + region = replaceOnce( + region, + ` writeUInt32BE(packet, 4 + algoLen + 4 + sigLen, p += pubKeyLen);`, + ` writeUInt32BE( + packet, + 4 + algoLen + 4 + sigLen + (skTrailer ? skTrailer.length : 0), + p += pubKeyLen + );`, + "authPK length", + ); + region = replaceOnce( + region, + ` packet.set(signature, p += 4);`, + ` packet.set(signature, p += 4); + if (skTrailer) + packet.set(skTrailer, p + sigLen);`, + "authPK copy", + ); + return source.slice(0, start) + region + source.slice(end); +} + +if (require.main === module) { + const targets = [ + ["ssh2/lib/protocol/keyParser.js", patchKeyParserSource], + ["ssh2/lib/agent.js", patchAgentSource], + ["ssh2/lib/protocol/Protocol.js", patchProtocolSource], + ]; + for (const [id, patch] of targets) { + const target = require.resolve(id); + const source = fs.readFileSync(target, "utf8"); + const result = patch(source); + if (result !== source) fs.writeFileSync(target, result); + } + console.log( + "[patch-ssh2-sk] Security key public keys and signatures enabled", + ); +} + +module.exports = { + patchKeyParserSource, + patchAgentSource, + patchProtocolSource, +}; diff --git a/src/backend/tests/hosts/ssh2-sk-patch.test.ts b/src/backend/tests/hosts/ssh2-sk-patch.test.ts new file mode 100644 index 000000000..39f6ad135 --- /dev/null +++ b/src/backend/tests/hosts/ssh2-sk-patch.test.ts @@ -0,0 +1,111 @@ +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; +import { generateKeyPairSync } from "node:crypto"; +import { expect, it } from "vitest"; +const require = createRequire(import.meta.url); +const ssh2 = require("ssh2"); +const { + patchKeyParserSource, + patchAgentSource, + patchProtocolSource, +} = require("../../../../scripts/patch-ssh2-sk.cjs"); + +const SK_ED = "sk-ssh-ed25519@openssh.com"; + +function str(value: Buffer | string): Buffer { + const buf = Buffer.isBuffer(value) ? value : Buffer.from(value); + const len = Buffer.alloc(4); + len.writeUInt32BE(buf.length); + return Buffer.concat([len, buf]); +} + +function skPublicBlob(): Buffer { + const { publicKey } = generateKeyPairSync("ed25519"); + const raw = publicKey.export({ format: "der", type: "spki" }).subarray(-32); + return Buffer.concat([str(SK_ED), str(raw), str("ssh:")]); +} + +it("patches are idempotent on the installed dependency", () => { + const files: Array<[string, (s: string) => string]> = [ + ["ssh2/lib/protocol/keyParser.js", patchKeyParserSource], + ["ssh2/lib/agent.js", patchAgentSource], + ["ssh2/lib/protocol/Protocol.js", patchProtocolSource], + ]; + for (const [id, patch] of files) { + const source = readFileSync(require.resolve(id), "utf8"); + const result = patch(source); + expect(patch(result)).toBe(result); + } +}); + +it("fails explicitly when the dependency changes", () => { + expect(() => patchKeyParserSource("changed")).toThrow("review"); + expect(() => patchAgentSource("changed")).toThrow("review"); + expect(() => patchProtocolSource("changed")).toThrow("review"); +}); + +it("parses a security key public blob and keeps it byte for byte", () => { + const blob = skPublicBlob(); + const key = ssh2.utils.parseKey(blob); + expect(key).not.toBeInstanceOf(Error); + expect(key.type).toBe(SK_ED); + expect(Buffer.compare(key.getPublicSSH(), blob)).toBe(0); +}); + +it("sends flags and counter after the signature in publickey auth", async () => { + const blob = skPublicBlob(); + const key = ssh2.utils.parseKey(blob); + const trailer = Buffer.from([0x01, 0, 0, 0, 7]); + const signature = Buffer.alloc(64, 9); + (signature as Buffer & { skTrailer?: Buffer }).skTrailer = trailer; + + class FakeAgent extends ssh2.BaseAgent { + getIdentities(cb: (err: Error | null, keys?: unknown[]) => void) { + cb(null, [key]); + } + sign( + _key: unknown, + _data: Buffer, + _opts: unknown, + cb: (err: Error | null, sig?: Buffer) => void, + ) { + cb(null, signature); + } + } + + const hostKey = ssh2.utils.generateKeyPairSync("ed25519").private; + const received = await new Promise((resolve) => { + const server = new ssh2.Server({ hostKeys: [hostKey] }, (client: any) => { + client.on("authentication", (ctx: any) => { + if (ctx.method !== "publickey") return ctx.reject(["publickey"]); + if (!ctx.signature) return ctx.accept(); + resolve( + Buffer.concat([ + Buffer.from(ctx.key.data), + Buffer.from(ctx.signature), + ]), + ); + ctx.reject(); + }); + client.on("error", () => {}); + }); + server.listen(0, "127.0.0.1", () => { + const conn = new ssh2.Client(); + conn.on("error", () => {}); + conn.on("close", () => server.close()); + conn.connect({ + host: "127.0.0.1", + port: server.address().port, + username: "u", + agent: new FakeAgent(), + readyTimeout: 5000, + }); + }); + }); + expect( + Buffer.compare( + received, + Buffer.concat([blob, Buffer.alloc(64, 9), trailer]), + ), + ).toBe(0); +}); diff --git a/src/backend/tests/utils/ssh-key-utils.test.ts b/src/backend/tests/utils/ssh-key-utils.test.ts index 22b66e39f..5175e3993 100644 --- a/src/backend/tests/utils/ssh-key-utils.test.ts +++ b/src/backend/tests/utils/ssh-key-utils.test.ts @@ -10,6 +10,7 @@ import { isPrivateKeyPassphraseError, getFriendlyKeyTypeName, validateKeyPair, + isSecurityKeyFile, } from "../../utils/ssh-key-utils.js"; const { parsePrivateKey } = sshpk; @@ -224,3 +225,43 @@ describe("PKCS#8 runtime key normalization", () => { ).toThrow(); }); }); + +describe("security key files", () => { + const str = (v: string | Buffer) => { + const b = Buffer.from(v); + const l = Buffer.alloc(4); + l.writeUInt32BE(b.length); + return Buffer.concat([l, b]); + }; + const skFile = (type: string) => { + const body = Buffer.concat([ + Buffer.from("openssh-key-v1\u0000"), + str("none"), + str("none"), + str(""), + Buffer.from([0, 0, 0, 1]), + str(Buffer.concat([str(type), str(Buffer.alloc(32, 1)), str("ssh:")])), + str(Buffer.alloc(16)), + ]).toString("base64"); + return `-----BEGIN OPENSSH PRIVATE KEY----- +${body} +-----END OPENSSH PRIVATE KEY-----`; + }; + + it("detects sk key files", () => { + expect(isSecurityKeyFile(skFile("sk-ssh-ed25519@openssh.com"))).toBe(true); + expect( + isSecurityKeyFile(skFile("sk-ecdsa-sha2-nistp256@openssh.com")), + ).toBe(true); + }); + + it("ignores normal keys", () => { + expect(isSecurityKeyFile(ED25519_PRIVATE)).toBe(false); + }); + + it("explains how to use a security key instead of failing to decode", () => { + expect(() => + preparePrivateKeyForSSH2(skFile("sk-ssh-ed25519@openssh.com")), + ).toThrow("ssh-add"); + }); +}); diff --git a/src/backend/utils/ssh-key-utils.ts b/src/backend/utils/ssh-key-utils.ts index 3ee4bb0b2..5bd699fd9 100644 --- a/src/backend/utils/ssh-key-utils.ts +++ b/src/backend/utils/ssh-key-utils.ts @@ -245,10 +245,30 @@ function getUnsupportedPrivateKeyError(privateKeyData: string): string { return "Unsupported private key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key."; } +const SECURITY_KEY_ERROR = + "This is a security key (FIDO2) key file. It only holds a handle, so the key itself must sign on your device. Load it with ssh-add and use SSH agent authentication in the desktop app."; + +/** An OpenSSH private key file for a FIDO2 security key (sk-ssh-ed25519, sk-ecdsa). */ +export function isSecurityKeyFile(privateKeyData: string): boolean { + const match = privateKeyData.match( + /-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]*?)-----END OPENSSH PRIVATE KEY-----/, + ); + if (!match) return false; + try { + const body = Buffer.from(match[1].replace(/\s+/g, ""), "base64"); + return /sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com/.test( + body.toString("latin1"), + ); + } catch { + return false; + } +} + export function preparePrivateKeyForSSH2( privateKeyData: string, passphrase?: string, ): Buffer { + if (isSecurityKeyFile(privateKeyData)) throw new Error(SECURITY_KEY_ERROR); let cleanKey = normalizePrivateKeyText(privateKeyData); if ( /^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) &&