[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:
428
electron/bridges/hostKeyVerifier.test.cjs
Normal file
428
electron/bridges/hostKeyVerifier.test.cjs
Normal file
@@ -0,0 +1,428 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const {
|
||||
classifyHostKey,
|
||||
createHostVerifier,
|
||||
describeHostKey,
|
||||
handleResponse,
|
||||
normalizeFingerprint,
|
||||
} = require("./hostKeyVerifier.cjs");
|
||||
|
||||
const makeRawPublicKey = (keyType, body = "trusted imported host key") => {
|
||||
const type = Buffer.from(keyType);
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(type.length, 0);
|
||||
return Buffer.concat([length, type, Buffer.from(body)]);
|
||||
};
|
||||
|
||||
test("classifyHostKey prompts for unknown hosts", () => {
|
||||
const result = classifyHostKey({
|
||||
knownHosts: [],
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: "new-key",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "unknown");
|
||||
});
|
||||
|
||||
test("classifyHostKey trusts a matching known host fingerprint", () => {
|
||||
const result = classifyHostKey({
|
||||
knownHosts: [{
|
||||
id: "kh-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: "SHA256:trusted-key",
|
||||
discoveredAt: 1,
|
||||
}],
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: "trusted-key",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "trusted");
|
||||
});
|
||||
|
||||
test("classifyHostKey trusts a matching full known_hosts public key", () => {
|
||||
const rawKey = makeRawPublicKey("ssh-ed25519");
|
||||
const fingerprint = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
|
||||
const result = classifyHostKey({
|
||||
knownHosts: [{
|
||||
id: "kh-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
|
||||
discoveredAt: 1,
|
||||
}],
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint,
|
||||
});
|
||||
|
||||
assert.equal(result.status, "trusted");
|
||||
});
|
||||
|
||||
test("describeHostKey preserves the full public key from raw SSH key blobs", () => {
|
||||
const rawKey = makeRawPublicKey("ssh-ed25519");
|
||||
const result = describeHostKey(rawKey);
|
||||
|
||||
assert.equal(result.keyType, "ssh-ed25519");
|
||||
assert.equal(result.publicKey, `ssh-ed25519 ${rawKey.toString("base64")}`);
|
||||
});
|
||||
|
||||
test("classifyHostKey warns when a known host fingerprint changes", () => {
|
||||
const result = classifyHostKey({
|
||||
knownHosts: [{
|
||||
id: "kh-1",
|
||||
hostname: "192.0.2.10",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: "SHA256:old-key",
|
||||
discoveredAt: 1,
|
||||
}],
|
||||
hostname: "192.0.2.10",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: "new-key",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "changed");
|
||||
assert.equal(result.knownHost?.id, "kh-1");
|
||||
assert.equal(result.expectedFingerprint, "old-key");
|
||||
});
|
||||
|
||||
test("classifyHostKey treats the same hostname on a different port as unknown", () => {
|
||||
const result = classifyHostKey({
|
||||
knownHosts: [{
|
||||
id: "kh-1",
|
||||
hostname: "switch.local",
|
||||
port: 2222,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: "SHA256:trusted-key",
|
||||
discoveredAt: 1,
|
||||
}],
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: "trusted-key",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "unknown");
|
||||
});
|
||||
|
||||
test("classifyHostKey reports unknown when only the incoming key type is unknown", () => {
|
||||
// Without a confident key type from ssh2 we cannot tell whether this is a
|
||||
// rotation of the stored key or a brand-new algorithm; force the user back
|
||||
// through the first-time-trust path rather than scaring them with a
|
||||
// "fingerprint changed" warning (#972).
|
||||
const result = classifyHostKey({
|
||||
knownHosts: [{
|
||||
id: "kh-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: "SHA256:trusted-key",
|
||||
discoveredAt: 1,
|
||||
}],
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "unknown",
|
||||
fingerprint: "new-key",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "unknown");
|
||||
});
|
||||
|
||||
test("classifyHostKey reports unknown when the stored record has no key type", () => {
|
||||
// Legacy / imported records sometimes have an empty or "unknown" keyType.
|
||||
// Promoting those to "changed" on every connect was the root cause of #972;
|
||||
// treat them as not-comparable so the user re-confirms cleanly.
|
||||
const result = classifyHostKey({
|
||||
knownHosts: [{
|
||||
id: "kh-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "unknown",
|
||||
publicKey: "SHA256:trusted-key",
|
||||
discoveredAt: 1,
|
||||
}],
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: "new-key",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "unknown");
|
||||
});
|
||||
|
||||
test("classifyHostKey reports unknown when the server presents a different key type than any stored record", () => {
|
||||
// Server with ssh-rsa stored; presents ssh-ed25519 this time. OpenSSH treats
|
||||
// this as a new key offering, not a rotation; we match that behavior so a
|
||||
// host with multiple algorithms doesn't spam mismatch warnings on every
|
||||
// algorithm renegotiation.
|
||||
const result = classifyHostKey({
|
||||
knownHosts: [{
|
||||
id: "kh-rsa",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-rsa",
|
||||
publicKey: "SHA256:rsa-key",
|
||||
discoveredAt: 1,
|
||||
}],
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: "new-key",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "unknown");
|
||||
});
|
||||
|
||||
test("classifyHostKey prefers exact key type mismatches when a host has multiple keys", () => {
|
||||
const result = classifyHostKey({
|
||||
knownHosts: [{
|
||||
id: "kh-rsa",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-rsa",
|
||||
publicKey: "SHA256:rsa-key",
|
||||
discoveredAt: 1,
|
||||
}, {
|
||||
id: "kh-ed25519",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: "SHA256:ed25519-key",
|
||||
discoveredAt: 2,
|
||||
}],
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: "new-key",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "changed");
|
||||
assert.equal(result.knownHost?.id, "kh-ed25519");
|
||||
});
|
||||
|
||||
test("classifyHostKey does not pick an arbitrary known host when incoming key type is unknown", () => {
|
||||
const result = classifyHostKey({
|
||||
knownHosts: [{
|
||||
id: "kh-rsa",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-rsa",
|
||||
publicKey: "SHA256:rsa-key",
|
||||
discoveredAt: 1,
|
||||
}, {
|
||||
id: "kh-ed25519",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: "SHA256:ed25519-key",
|
||||
discoveredAt: 2,
|
||||
}],
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "unknown",
|
||||
fingerprint: "new-key",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "unknown");
|
||||
});
|
||||
|
||||
test("normalizeFingerprint accepts SHA256-prefixed values", () => {
|
||||
assert.equal(normalizeFingerprint("SHA256:abc123==="), "abc123");
|
||||
});
|
||||
|
||||
test("createHostVerifier accepts trusted host keys without prompting", async () => {
|
||||
const rawKey = Buffer.from("trusted server key");
|
||||
const fingerprint = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
const verifier = createHostVerifier({
|
||||
sender,
|
||||
sessionId: "session-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
knownHosts: [{
|
||||
id: "kh-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "unknown",
|
||||
publicKey: `SHA256:${fingerprint}`,
|
||||
discoveredAt: 1,
|
||||
}],
|
||||
});
|
||||
|
||||
const accepted = await new Promise((resolve) => verifier(rawKey, resolve));
|
||||
|
||||
assert.equal(accepted, true);
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test("createHostVerifier skips prompts when host key verification is disabled", async () => {
|
||||
const rawKey = Buffer.from("untrusted server key");
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
const verifier = createHostVerifier({
|
||||
sender,
|
||||
sessionId: "session-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
knownHosts: [],
|
||||
verifyHostKeys: false,
|
||||
});
|
||||
|
||||
const accepted = await new Promise((resolve) => verifier(rawKey, resolve));
|
||||
|
||||
assert.equal(accepted, true);
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test("createHostVerifier accepts imported full known_hosts public keys without prompting", async () => {
|
||||
const rawKey = makeRawPublicKey("ssh-ed25519");
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
const verifier = createHostVerifier({
|
||||
sender,
|
||||
sessionId: "session-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
knownHosts: [{
|
||||
id: "kh-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
|
||||
discoveredAt: 1,
|
||||
}],
|
||||
});
|
||||
|
||||
const accepted = await new Promise((resolve) => verifier(rawKey, resolve));
|
||||
|
||||
assert.equal(accepted, true);
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test("createHostVerifier prompts for unknown host keys and waits for user response", async () => {
|
||||
const rawKey = Buffer.from("new server key");
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
const verifier = createHostVerifier({
|
||||
sender,
|
||||
sessionId: "session-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
knownHosts: [],
|
||||
});
|
||||
|
||||
const acceptedPromise = new Promise((resolve) => verifier(rawKey, resolve));
|
||||
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].channel, "netcatty:host-key:verify");
|
||||
assert.equal(sent[0].payload.hostname, "switch.local");
|
||||
assert.equal(sent[0].payload.status, "unknown");
|
||||
|
||||
handleResponse({ sender: { id: sender.id } }, {
|
||||
requestId: sent[0].payload.requestId,
|
||||
accept: true,
|
||||
addToKnownHosts: true,
|
||||
});
|
||||
|
||||
assert.equal(await acceptedPromise, true);
|
||||
});
|
||||
|
||||
test("createHostVerifier forwards bootEpoch on host-key prompts", async () => {
|
||||
const rawKey = Buffer.from("new server key");
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
const verifier = createHostVerifier({
|
||||
sender,
|
||||
sessionId: "session-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
knownHosts: [],
|
||||
bootEpoch: 7,
|
||||
});
|
||||
|
||||
const acceptedPromise = new Promise((resolve) => verifier(rawKey, resolve));
|
||||
assert.equal(sent[0].payload.bootEpoch, 7);
|
||||
|
||||
handleResponse({ sender: { id: sender.id } }, {
|
||||
requestId: sent[0].payload.requestId,
|
||||
accept: false,
|
||||
});
|
||||
assert.equal(await acceptedPromise, false);
|
||||
});
|
||||
|
||||
test("createHostVerifier includes existing known host details when a key changes", async () => {
|
||||
// A well-formed wire blob so `describeHostKey` can recover keyType =
|
||||
// "ssh-ed25519"; that triggers the strict (host, port, type) mismatch
|
||||
// branch with a stored record of the same type but different fingerprint.
|
||||
const rawKey = makeRawPublicKey("ssh-ed25519", "changed server key");
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
const verifier = createHostVerifier({
|
||||
sender,
|
||||
sessionId: "session-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
knownHosts: [{
|
||||
id: "kh-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: "SHA256:old-key",
|
||||
fingerprint: "old-key",
|
||||
discoveredAt: 1,
|
||||
}],
|
||||
});
|
||||
|
||||
const acceptedPromise = new Promise((resolve) => verifier(rawKey, resolve));
|
||||
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].channel, "netcatty:host-key:verify");
|
||||
assert.equal(sent[0].payload.status, "changed");
|
||||
assert.equal(sent[0].payload.knownHostId, "kh-1");
|
||||
assert.equal(sent[0].payload.knownFingerprint, "old-key");
|
||||
|
||||
handleResponse({ sender: { id: sender.id } }, {
|
||||
requestId: sent[0].payload.requestId,
|
||||
accept: true,
|
||||
addToKnownHosts: true,
|
||||
});
|
||||
|
||||
assert.equal(await acceptedPromise, true);
|
||||
});
|
||||
Reference in New Issue
Block a user