[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
379
electron/bridges/ppkConverter.test.cjs
Normal file
379
electron/bridges/ppkConverter.test.cjs
Normal file
@@ -0,0 +1,379 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const { utils: sshUtils } = require("ssh2");
|
||||
|
||||
const { convertPpkToOpenSsh, serializePpk } = require("./ppkConverter.cjs");
|
||||
const {
|
||||
normalizePrivateKeyForSsh2,
|
||||
PrivateKeyPassphraseError,
|
||||
UnsupportedPrivateKeyError,
|
||||
} = require("./privateKeyNormalizer.cjs");
|
||||
const { preparePrivateKeyForAuth, isKeyEncrypted } = require("./sshAuthHelper.cjs");
|
||||
|
||||
const hasArgon2 = typeof crypto.argon2Sync === "function";
|
||||
|
||||
function u32(value) {
|
||||
const buf = Buffer.allocUnsafe(4);
|
||||
buf.writeUInt32BE(value);
|
||||
return buf;
|
||||
}
|
||||
|
||||
function writeBytes(buf) {
|
||||
return Buffer.concat([u32(buf.length), buf]);
|
||||
}
|
||||
|
||||
function writeString(value) {
|
||||
return writeBytes(Buffer.from(value, "utf8"));
|
||||
}
|
||||
|
||||
function writeMpint(buf) {
|
||||
let value = buf;
|
||||
while (value.length > 1 && value[0] === 0) value = value.subarray(1);
|
||||
if (value[0] & 0x80) value = Buffer.concat([Buffer.from([0]), value]);
|
||||
return writeBytes(value);
|
||||
}
|
||||
|
||||
function parseOk(key) {
|
||||
const parsed = sshUtils.parseKey(key);
|
||||
return parsed && !(parsed instanceof Error) ? parsed : null;
|
||||
}
|
||||
|
||||
function ed25519Blobs() {
|
||||
const { privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const jwk = privateKey.export({ format: "jwk" });
|
||||
const seed = Buffer.from(jwk.d, "base64url");
|
||||
const pub = Buffer.from(jwk.x, "base64url");
|
||||
return {
|
||||
seed,
|
||||
pub,
|
||||
publicBlob: Buffer.concat([writeString("ssh-ed25519"), writeBytes(pub)]),
|
||||
privateBlob: writeBytes(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function ecdsaP256Blobs() {
|
||||
const { privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
||||
const jwk = privateKey.export({ format: "jwk" });
|
||||
const x = Buffer.from(jwk.x, "base64url");
|
||||
const y = Buffer.from(jwk.y, "base64url");
|
||||
const d = Buffer.from(jwk.d, "base64url");
|
||||
const point = Buffer.concat([Buffer.from([0x04]), x, y]);
|
||||
return {
|
||||
publicBlob: Buffer.concat([
|
||||
writeString("ecdsa-sha2-nistp256"),
|
||||
writeString("nistp256"),
|
||||
writeBytes(point),
|
||||
]),
|
||||
privateBlob: writeMpint(d),
|
||||
};
|
||||
}
|
||||
|
||||
function rsaBlobs() {
|
||||
const { privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const jwk = privateKey.export({ format: "jwk" });
|
||||
const n = Buffer.from(jwk.n, "base64url");
|
||||
const e = Buffer.from(jwk.e, "base64url");
|
||||
const d = Buffer.from(jwk.d, "base64url");
|
||||
const p = Buffer.from(jwk.p, "base64url");
|
||||
const q = Buffer.from(jwk.q, "base64url");
|
||||
const qi = Buffer.from(jwk.qi, "base64url");
|
||||
return {
|
||||
publicBlob: Buffer.concat([writeString("ssh-rsa"), writeMpint(e), writeMpint(n)]),
|
||||
privateBlob: Buffer.concat([writeMpint(d), writeMpint(p), writeMpint(q), writeMpint(qi)]),
|
||||
};
|
||||
}
|
||||
|
||||
function ed25519Ppk(options = {}) {
|
||||
const blobs = ed25519Blobs();
|
||||
const ppk = serializePpk({
|
||||
version: options.version ?? 2,
|
||||
type: "ssh-ed25519",
|
||||
comment: options.comment ?? "ed25519-test",
|
||||
publicBlob: blobs.publicBlob,
|
||||
privateBlob: blobs.privateBlob,
|
||||
passphrase: options.passphrase,
|
||||
});
|
||||
return { ...blobs, ppk };
|
||||
}
|
||||
|
||||
test("ssh2 cannot parse an encrypted Ed25519 PPK v2 even with the correct passphrase", () => {
|
||||
const { ppk } = ed25519Ppk({ passphrase: "correct horse" });
|
||||
assert.match(ppk, /^PuTTY-User-Key-File-2: ssh-ed25519/m);
|
||||
const parsed = sshUtils.parseKey(ppk, "correct horse");
|
||||
assert.ok(parsed instanceof Error, "ssh2 should reject Ed25519 PPK");
|
||||
});
|
||||
|
||||
test("converts an encrypted Ed25519 PPK v2 into an ssh2-parseable OpenSSH key", () => {
|
||||
const { ppk, pub } = ed25519Ppk({ passphrase: "secret" });
|
||||
const result = normalizePrivateKeyForSsh2(ppk, "secret");
|
||||
assert.equal(result.converted, true);
|
||||
assert.equal(result.passphrase, undefined);
|
||||
const parsed = parseOk(result.privateKey);
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.type, "ssh-ed25519");
|
||||
assert.deepEqual(parsed.getPublicSSH().subarray(-32), pub);
|
||||
});
|
||||
|
||||
test("converts an unencrypted Ed25519 PPK v2", () => {
|
||||
const { ppk } = ed25519Ppk();
|
||||
const result = normalizePrivateKeyForSsh2(ppk);
|
||||
assert.equal(result.converted, true);
|
||||
assert.ok(parseOk(result.privateKey));
|
||||
});
|
||||
|
||||
test("converts an encrypted Ed25519 PPK with CRLF line endings", () => {
|
||||
const { ppk } = ed25519Ppk({ passphrase: "secret" });
|
||||
const crlf = ppk.replace(/\n/g, "\r\n");
|
||||
const result = normalizePrivateKeyForSsh2(crlf, "secret");
|
||||
assert.equal(result.converted, true);
|
||||
assert.ok(parseOk(result.privateKey));
|
||||
});
|
||||
|
||||
function stubEncryptedPpkV3({ memory = 8192, passes = 2, parallelism = 1 } = {}) {
|
||||
return [
|
||||
"PuTTY-User-Key-File-3: ssh-ed25519",
|
||||
"Encryption: aes256-cbc",
|
||||
"Comment: stub",
|
||||
"Public-Lines: 1",
|
||||
"AAAA",
|
||||
"Key-Derivation: Argon2id",
|
||||
`Argon2-Memory: ${memory}`,
|
||||
`Argon2-Passes: ${passes}`,
|
||||
`Argon2-Parallelism: ${parallelism}`,
|
||||
"Argon2-Salt: 00112233445566778899aabbccddeeff",
|
||||
"Private-Lines: 1",
|
||||
"AAAA",
|
||||
"Private-MAC: 00",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
test("rejects an encrypted PPK v3 with unbounded Argon2 memory before deriving keys", () => {
|
||||
const started = Date.now();
|
||||
assert.throws(
|
||||
() => normalizePrivateKeyForSsh2(stubEncryptedPpkV3({ memory: 999999999 }), "secret"),
|
||||
(err) => err instanceof UnsupportedPrivateKeyError && /Argon2 parameters exceed supported limits/.test(err.message),
|
||||
);
|
||||
assert.ok(Date.now() - started < 250, "oversized Argon2 work factors must be rejected without deriving");
|
||||
});
|
||||
|
||||
test("rejects an encrypted PPK v3 with a non-positive Argon2 pass count", () => {
|
||||
assert.throws(
|
||||
() => normalizePrivateKeyForSsh2(stubEncryptedPpkV3({ passes: 0 }), "secret"),
|
||||
(err) => err instanceof UnsupportedPrivateKeyError && /Argon2 parameters exceed supported limits/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
test("throws PrivateKeyPassphraseError for encrypted Ed25519 PPK with the wrong passphrase", () => {
|
||||
const { ppk } = ed25519Ppk({ passphrase: "secret" });
|
||||
assert.throws(
|
||||
() => normalizePrivateKeyForSsh2(ppk, "wrong"),
|
||||
(err) => err instanceof PrivateKeyPassphraseError,
|
||||
);
|
||||
});
|
||||
|
||||
test("throws PrivateKeyPassphraseError when an encrypted PPK has no passphrase", () => {
|
||||
const { ppk } = ed25519Ppk({ passphrase: "secret" });
|
||||
assert.throws(
|
||||
() => normalizePrivateKeyForSsh2(ppk),
|
||||
(err) => err instanceof PrivateKeyPassphraseError,
|
||||
);
|
||||
});
|
||||
|
||||
test("converts an encrypted Ed25519 PPK v3 using Argon2id", { skip: !hasArgon2 }, () => {
|
||||
const { ppk, pub } = ed25519Ppk({ version: 3, passphrase: "secret" });
|
||||
assert.match(ppk, /^PuTTY-User-Key-File-3: ssh-ed25519/m);
|
||||
assert.match(ppk, /^Key-Derivation: Argon2id/m);
|
||||
const ssh2Parsed = sshUtils.parseKey(ppk, "secret");
|
||||
assert.ok(ssh2Parsed instanceof Error, "ssh2 should reject PPK v3");
|
||||
const result = normalizePrivateKeyForSsh2(ppk, "secret");
|
||||
assert.equal(result.converted, true);
|
||||
const parsed = parseOk(result.privateKey);
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.type, "ssh-ed25519");
|
||||
assert.deepEqual(parsed.getPublicSSH().subarray(-32), pub);
|
||||
});
|
||||
|
||||
test("converts an encrypted ECDSA P-256 PPK v2", () => {
|
||||
const blobs = ecdsaP256Blobs();
|
||||
const ppk = serializePpk({
|
||||
version: 2,
|
||||
type: "ecdsa-sha2-nistp256",
|
||||
comment: "ecdsa-test",
|
||||
publicBlob: blobs.publicBlob,
|
||||
privateBlob: blobs.privateBlob,
|
||||
passphrase: "secret",
|
||||
});
|
||||
const result = normalizePrivateKeyForSsh2(ppk, "secret");
|
||||
assert.equal(result.converted, true);
|
||||
const parsed = parseOk(result.privateKey);
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.type, "ecdsa-sha2-nistp256");
|
||||
});
|
||||
|
||||
test("converts an encrypted RSA PPK v3 that ssh2 cannot parse", { skip: !hasArgon2 }, () => {
|
||||
const blobs = rsaBlobs();
|
||||
const ppk = serializePpk({
|
||||
version: 3,
|
||||
type: "ssh-rsa",
|
||||
comment: "rsa-v3",
|
||||
publicBlob: blobs.publicBlob,
|
||||
privateBlob: blobs.privateBlob,
|
||||
passphrase: "secret",
|
||||
});
|
||||
assert.ok(sshUtils.parseKey(ppk, "secret") instanceof Error);
|
||||
const result = normalizePrivateKeyForSsh2(ppk, "secret");
|
||||
assert.equal(result.converted, true);
|
||||
const parsed = parseOk(result.privateKey);
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.type, "ssh-rsa");
|
||||
});
|
||||
|
||||
test("leaves a PPK v2 RSA key to ssh2 when it can already parse it", () => {
|
||||
const blobs = rsaBlobs();
|
||||
const ppk = serializePpk({
|
||||
version: 2,
|
||||
type: "ssh-rsa",
|
||||
comment: "rsa-v2",
|
||||
publicBlob: blobs.publicBlob,
|
||||
privateBlob: blobs.privateBlob,
|
||||
passphrase: "secret",
|
||||
});
|
||||
const ssh2Parsed = sshUtils.parseKey(ppk, "secret");
|
||||
assert.ok(ssh2Parsed && !(ssh2Parsed instanceof Error), "ssh2 should still parse RSA PPK v2");
|
||||
const result = normalizePrivateKeyForSsh2(ppk, "secret");
|
||||
assert.equal(result.converted, false);
|
||||
assert.equal(result.privateKey, ppk);
|
||||
});
|
||||
|
||||
test("convertPpkToOpenSsh returns null for non-PPK input", () => {
|
||||
assert.equal(convertPpkToOpenSsh("-----BEGIN OPENSSH PRIVATE KEY-----"), null);
|
||||
});
|
||||
|
||||
test("rejects an unsupported PPK algorithm with UnsupportedPrivateKeyError", () => {
|
||||
const ppk = [
|
||||
"PuTTY-User-Key-File-2: ssh-unknown",
|
||||
"Encryption: none",
|
||||
"Comment: nope",
|
||||
"Public-Lines: 1",
|
||||
"AAAA",
|
||||
"Private-Lines: 1",
|
||||
"AAAA",
|
||||
"Private-MAC: 00",
|
||||
"",
|
||||
].join("\n");
|
||||
assert.throws(
|
||||
() => normalizePrivateKeyForSsh2(ppk),
|
||||
(err) => err instanceof UnsupportedPrivateKeyError && /ssh-unknown/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
// Real PuTTYgen fixtures from SshAgentLib (passphrase: "correct horse battery staple").
|
||||
const PUTTYGEN_ED25519_V2 = `PuTTY-User-Key-File-2: ssh-ed25519
|
||||
Encryption: aes256-cbc
|
||||
Comment: eddsa-key-20220506
|
||||
Public-Lines: 2
|
||||
AAAAC3NzaC1lZDI1NTE5AAAAIH+QE+UNYtz7N9RX2FseJmmzIroOs24UzTsJP6kj
|
||||
0gxU
|
||||
Private-Lines: 1
|
||||
ssG0o2XpU0dez67/t5sffDp5j41eXMt7ViZpeB7O1jtA7NLOQ3Q3UjAPBQJCR/Vs
|
||||
Private-MAC: 8d45d4218f19b0677d6b51529ef43236468f0de9
|
||||
`;
|
||||
|
||||
const PUTTYGEN_ED25519_V3_NONE = `PuTTY-User-Key-File-3: ssh-ed25519
|
||||
Encryption: none
|
||||
Comment: eddsa-key-20220506
|
||||
Public-Lines: 2
|
||||
AAAAC3NzaC1lZDI1NTE5AAAAIH+QE+UNYtz7N9RX2FseJmmzIroOs24UzTsJP6kj
|
||||
0gxU
|
||||
Private-Lines: 1
|
||||
AAAAIAOsb28qs/Ob4JfyCCGqcONFEtlWkqquOryLlfbjebBp
|
||||
Private-MAC: fd89c96303e82b9c7f3ddbec3884ebd5a3500da8d4f2e9716c0494f3c207fffa
|
||||
`;
|
||||
|
||||
const PUTTYGEN_ED25519_V3 = `PuTTY-User-Key-File-3: ssh-ed25519
|
||||
Encryption: aes256-cbc
|
||||
Comment: eddsa-key-20220506
|
||||
Public-Lines: 2
|
||||
AAAAC3NzaC1lZDI1NTE5AAAAIH+QE+UNYtz7N9RX2FseJmmzIroOs24UzTsJP6kj
|
||||
0gxU
|
||||
Key-Derivation: Argon2id
|
||||
Argon2-Memory: 8192
|
||||
Argon2-Passes: 1
|
||||
Argon2-Parallelism: 1
|
||||
Argon2-Salt: 28cb68bdb97eaae0d3a71cb285dff1e0
|
||||
Private-Lines: 1
|
||||
mqR8KyKjLx8vZlOMIaP5ira+8z5uV9DsmiChSpOK0ut4FyQJtYkRKi6InQcdz1Sa
|
||||
Private-MAC: 0a0cc345089719caa38a7990ed9b6dd9b38792fd553a84e54be01885d240df83
|
||||
`;
|
||||
|
||||
const PUTTYGEN_PASSPHRASE = "correct horse battery staple";
|
||||
|
||||
test("converts a real PuTTYgen unencrypted Ed25519 PPK v3", () => {
|
||||
const result = normalizePrivateKeyForSsh2(PUTTYGEN_ED25519_V3_NONE);
|
||||
assert.equal(result.converted, true);
|
||||
const parsed = parseOk(result.privateKey);
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.type, "ssh-ed25519");
|
||||
});
|
||||
|
||||
test("converts an unencrypted generated Ed25519 PPK v3", () => {
|
||||
const { ppk } = ed25519Ppk({ version: 3 });
|
||||
assert.match(ppk, /^PuTTY-User-Key-File-3: ssh-ed25519/m);
|
||||
assert.match(ppk, /^Encryption: none/m);
|
||||
const result = normalizePrivateKeyForSsh2(ppk);
|
||||
assert.equal(result.converted, true);
|
||||
assert.ok(parseOk(result.privateKey));
|
||||
});
|
||||
|
||||
test("preparePrivateKeyForAuth returns an unlocked OpenSSH key for encrypted PPK", async () => {
|
||||
const { ppk } = ed25519Ppk({ passphrase: "secret" });
|
||||
const result = await preparePrivateKeyForAuth({
|
||||
sender: { isDestroyed: () => false, send: () => {} },
|
||||
privateKey: ppk,
|
||||
keyName: "putty-ed25519.ppk",
|
||||
hostname: "example.test",
|
||||
initialPassphrase: "secret",
|
||||
logPrefix: "[Test]",
|
||||
});
|
||||
assert.ok(result);
|
||||
assert.equal(isKeyEncrypted(ppk), true);
|
||||
assert.equal(isKeyEncrypted(result.privateKey), false);
|
||||
assert.equal(result.passphrase, undefined);
|
||||
});
|
||||
|
||||
test("decrypts a real PuTTYgen encrypted Ed25519 PPK v2", () => {
|
||||
assert.ok(sshUtils.parseKey(PUTTYGEN_ED25519_V2, PUTTYGEN_PASSPHRASE) instanceof Error);
|
||||
const result = normalizePrivateKeyForSsh2(PUTTYGEN_ED25519_V2, PUTTYGEN_PASSPHRASE);
|
||||
assert.equal(result.converted, true);
|
||||
const parsed = parseOk(result.privateKey);
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.type, "ssh-ed25519");
|
||||
assert.equal(parsed.comment, "eddsa-key-20220506");
|
||||
});
|
||||
|
||||
test("decrypts a real PuTTYgen encrypted Ed25519 PPK v3 (Argon2id)", { skip: !hasArgon2 }, () => {
|
||||
assert.ok(sshUtils.parseKey(PUTTYGEN_ED25519_V3, PUTTYGEN_PASSPHRASE) instanceof Error);
|
||||
const result = normalizePrivateKeyForSsh2(PUTTYGEN_ED25519_V3, PUTTYGEN_PASSPHRASE);
|
||||
assert.equal(result.converted, true);
|
||||
const parsed = parseOk(result.privateKey);
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.type, "ssh-ed25519");
|
||||
assert.equal(parsed.comment, "eddsa-key-20220506");
|
||||
});
|
||||
|
||||
test("preparePrivateKeyForAuth decrypts an encrypted Ed25519 PPK", async () => {
|
||||
const { ppk } = ed25519Ppk({ passphrase: "secret" });
|
||||
const result = await preparePrivateKeyForAuth({
|
||||
sender: { isDestroyed: () => false, send: () => {} },
|
||||
privateKey: ppk,
|
||||
keyName: "putty-ed25519.ppk",
|
||||
hostname: "example.test",
|
||||
initialPassphrase: "secret",
|
||||
logPrefix: "[Test]",
|
||||
});
|
||||
assert.ok(result);
|
||||
assert.ok(parseOk(result.privateKey));
|
||||
assert.equal(result.passphrase, undefined);
|
||||
});
|
||||
Reference in New Issue
Block a user