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
429 lines
12 KiB
JavaScript
429 lines
12 KiB
JavaScript
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);
|
|
});
|