feat: support FIDO2 security keys through ssh agent

This commit is contained in:
LukeGus committed 2026-10-19 19:55:35 -05:00
1 parent 901a235de4
commit 3b3fd7f135
6 files changed
+324 -1

No files matched your search

@@ -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<Buffer>((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);
});
@@ -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");
});
});
+20
View File
@@ -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) &&