mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
feat: support FIDO2 security keys through ssh agent
This commit is contained in:
1 parent
901a235de4
commit
3b3fd7f135
6 files changed
+324
-1
No files matched your search
@@ -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
|
- 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
|
- 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
|
- 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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"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",
|
"prepare": "husky || true",
|
||||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
"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",
|
"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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
isPrivateKeyPassphraseError,
|
||||||
getFriendlyKeyTypeName,
|
getFriendlyKeyTypeName,
|
||||||
validateKeyPair,
|
validateKeyPair,
|
||||||
|
isSecurityKeyFile,
|
||||||
} from "../../utils/ssh-key-utils.js";
|
} from "../../utils/ssh-key-utils.js";
|
||||||
|
|
||||||
const { parsePrivateKey } = sshpk;
|
const { parsePrivateKey } = sshpk;
|
||||||
@@ -224,3 +225,43 @@ describe("PKCS#8 runtime key normalization", () => {
|
|||||||
).toThrow();
|
).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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.";
|
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(
|
export function preparePrivateKeyForSSH2(
|
||||||
privateKeyData: string,
|
privateKeyData: string,
|
||||||
passphrase?: string,
|
passphrase?: string,
|
||||||
): Buffer {
|
): Buffer {
|
||||||
|
if (isSecurityKeyFile(privateKeyData)) throw new Error(SECURITY_KEY_ERROR);
|
||||||
let cleanKey = normalizePrivateKeyText(privateKeyData);
|
let cleanKey = normalizePrivateKeyText(privateKeyData);
|
||||||
if (
|
if (
|
||||||
/^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) &&
|
/^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) &&
|
||||||
|
|||||||
Reference in new issue
Block a user