The host-key verifier was misclassifying connections as `changed` in three situations that had nothing to do with a real key rotation: 1. Records imported from the system `~/.ssh/known_hosts` (or older builds) landed in localStorage without a `fingerprint` field. The verifier then re-derived the fingerprint from the stored `publicKey` blob on every connect — a brittle path that produced a different value than ssh2 if anything about the serialization differed by even one byte. 2. `classifyHostKey` had a loose "single candidate with unknown / empty keyType → changed" heuristic. Any imported record whose keyType failed to parse would be promoted to a rotation warning the first time the server presented a real algorithm, even though the user had never actually trusted any fingerprint for that algorithm. 3. A host that genuinely had multiple algorithms (e.g. one stored ssh-rsa record plus a live ssh-ed25519 handshake) was being reported as `changed` instead of `unknown`, even though we had no comparable record for the algorithm the server presented. Tabby (`tabby-ssh/src/session/ssh.ts`) and OpenSSH both treat case (3) as a first-time prompt rather than a mismatch; this change brings Netcatty in line with that model. Changes: - `domain/knownHosts.ts` ports `fingerprintFromPublicKey` to TS and adds `normalizeKnownHost` / `normalizeKnownHosts` so the renderer can backfill legacy records on hydration. Pure-JS SHA-256 keeps the migration synchronous so it can run inline in `useVaultState` without async plumbing. - `application/state/useVaultState.ts` runs the migration on hydration and on cross-window storage events. When anything changes on hydration the migrated list is written back to localStorage so the next launch starts clean. - `components/KnownHostsManager.tsx` populates `fingerprint` at import time instead of leaving it for the verifier to re-derive. - `electron/bridges/hostKeyVerifier.cjs` simplifies `classifyHostKey` to fingerprint-first, then strict (host, port, keyType) match for the changed branch, then fall through to `unknown`. Two existing tests that locked in the loose heuristic are updated to assert the new (safer) behavior, and a new test covers the multi-algorithm first-encounter case. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
261 lines
7.9 KiB
TypeScript
261 lines
7.9 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import crypto from "node:crypto";
|
|
|
|
import type { KnownHost } from "./models";
|
|
import {
|
|
fingerprintFromPublicKey,
|
|
normalizeKnownHost,
|
|
normalizeKnownHosts,
|
|
upsertKnownHost,
|
|
} from "./knownHosts";
|
|
|
|
const knownHost = (overrides: Partial<KnownHost> = {}): KnownHost => ({
|
|
id: "kh-existing",
|
|
hostname: "10.2.0.32",
|
|
port: 22,
|
|
keyType: "ssh-ed25519",
|
|
publicKey: "ssh-ed25519 old-key",
|
|
fingerprint: "old-fingerprint",
|
|
discoveredAt: 100,
|
|
...overrides,
|
|
});
|
|
|
|
test("upsertKnownHost updates an existing host key instead of appending a duplicate", () => {
|
|
const existing = knownHost({ convertedToHostId: "host-1" });
|
|
const incoming = knownHost({
|
|
id: "kh-new",
|
|
publicKey: "ssh-ed25519 new-key",
|
|
fingerprint: "new-fingerprint",
|
|
discoveredAt: 200,
|
|
});
|
|
|
|
const result = upsertKnownHost([existing], incoming);
|
|
|
|
assert.equal(result.length, 1);
|
|
assert.deepEqual(result[0], {
|
|
...existing,
|
|
publicKey: "ssh-ed25519 new-key",
|
|
fingerprint: "new-fingerprint",
|
|
lastSeen: 200,
|
|
});
|
|
});
|
|
|
|
test("upsertKnownHost updates by id even when the incoming key type is unknown", () => {
|
|
const existing = knownHost({
|
|
id: "kh-1",
|
|
keyType: "ssh-ed25519",
|
|
publicKey: "SHA256:old-key",
|
|
fingerprint: "old-fingerprint",
|
|
discoveredAt: 100,
|
|
});
|
|
const incoming = knownHost({
|
|
id: "kh-1",
|
|
keyType: "unknown",
|
|
publicKey: undefined,
|
|
fingerprint: "new-fingerprint",
|
|
discoveredAt: 200,
|
|
});
|
|
|
|
const result = upsertKnownHost([existing], incoming);
|
|
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].id, "kh-1");
|
|
assert.equal(result[0].keyType, "unknown");
|
|
assert.equal(result[0].fingerprint, "new-fingerprint");
|
|
assert.equal(result[0].lastSeen, 200);
|
|
});
|
|
|
|
test("upsertKnownHost prefers the matching id over an earlier selector match", () => {
|
|
const duplicate = knownHost({
|
|
id: "kh-duplicate",
|
|
fingerprint: "duplicate-fingerprint",
|
|
discoveredAt: 50,
|
|
});
|
|
const target = knownHost({
|
|
id: "kh-target",
|
|
fingerprint: "target-fingerprint",
|
|
discoveredAt: 100,
|
|
});
|
|
const incoming = knownHost({
|
|
id: "kh-target",
|
|
fingerprint: "new-fingerprint",
|
|
discoveredAt: 200,
|
|
});
|
|
|
|
const result = upsertKnownHost([duplicate, target], incoming);
|
|
|
|
assert.equal(result.length, 2);
|
|
assert.equal(result[0].fingerprint, "duplicate-fingerprint");
|
|
assert.equal(result[1].id, "kh-target");
|
|
assert.equal(result[1].fingerprint, "new-fingerprint");
|
|
});
|
|
|
|
test("upsertKnownHost appends genuinely new host keys", () => {
|
|
const existing = knownHost();
|
|
const incoming = knownHost({
|
|
id: "kh-other",
|
|
hostname: "10.2.0.33",
|
|
fingerprint: "other-fingerprint",
|
|
});
|
|
|
|
const result = upsertKnownHost([existing], incoming);
|
|
|
|
assert.deepEqual(result, [existing, incoming]);
|
|
});
|
|
|
|
// --- Fingerprint derivation -------------------------------------------------
|
|
|
|
const makeRawPublicKey = (keyType: string, 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("fingerprintFromPublicKey matches Node's SHA-256 over a base64-decoded OpenSSH line", () => {
|
|
const rawKey = makeRawPublicKey("ssh-ed25519");
|
|
const base64Body = rawKey.toString("base64");
|
|
const expected = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
|
|
|
|
assert.equal(fingerprintFromPublicKey(`ssh-ed25519 ${base64Body}`), expected);
|
|
assert.equal(
|
|
fingerprintFromPublicKey(`ssh-ed25519 ${base64Body} comment-tail`),
|
|
expected,
|
|
"trailing comment is ignored",
|
|
);
|
|
});
|
|
|
|
test("fingerprintFromPublicKey strips a SHA256: prefix and trailing padding", () => {
|
|
assert.equal(fingerprintFromPublicKey("SHA256:abc123=="), "abc123");
|
|
assert.equal(fingerprintFromPublicKey("sha256:abc123"), "abc123");
|
|
});
|
|
|
|
test("fingerprintFromPublicKey returns empty string on missing input", () => {
|
|
assert.equal(fingerprintFromPublicKey(undefined), "");
|
|
assert.equal(fingerprintFromPublicKey(null), "");
|
|
assert.equal(fingerprintFromPublicKey(""), "");
|
|
});
|
|
|
|
// --- Migration --------------------------------------------------------------
|
|
|
|
test("normalizeKnownHost backfills fingerprint when only publicKey is stored", () => {
|
|
const rawKey = makeRawPublicKey("ssh-ed25519");
|
|
const base64Body = rawKey.toString("base64");
|
|
const expected = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
|
|
|
|
const stored: KnownHost = {
|
|
id: "kh-1",
|
|
hostname: "vps-1.example.com",
|
|
port: 22,
|
|
keyType: "ssh-ed25519",
|
|
publicKey: `ssh-ed25519 ${base64Body}`,
|
|
discoveredAt: 1,
|
|
};
|
|
|
|
const migrated = normalizeKnownHost(stored);
|
|
assert.notEqual(migrated, stored, "should return a new object when fingerprint is added");
|
|
assert.equal(migrated.fingerprint, expected);
|
|
assert.equal(migrated.keyType, "ssh-ed25519");
|
|
});
|
|
|
|
test("normalizeKnownHost backfills keyType from an OpenSSH-format publicKey", () => {
|
|
const rawKey = makeRawPublicKey("ssh-rsa");
|
|
const base64Body = rawKey.toString("base64");
|
|
|
|
const stored: KnownHost = {
|
|
id: "kh-1",
|
|
hostname: "vps-1.example.com",
|
|
port: 22,
|
|
keyType: "",
|
|
publicKey: `ssh-rsa ${base64Body}`,
|
|
discoveredAt: 1,
|
|
};
|
|
|
|
const migrated = normalizeKnownHost(stored);
|
|
assert.equal(migrated.keyType, "ssh-rsa");
|
|
});
|
|
|
|
test("normalizeKnownHost returns the same reference when nothing needs backfilling", () => {
|
|
const rawKey = makeRawPublicKey("ssh-ed25519");
|
|
const fp = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
|
|
|
|
const stored: KnownHost = {
|
|
id: "kh-1",
|
|
hostname: "vps-1.example.com",
|
|
port: 22,
|
|
keyType: "ssh-ed25519",
|
|
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
|
|
fingerprint: fp,
|
|
discoveredAt: 1,
|
|
};
|
|
|
|
assert.equal(normalizeKnownHost(stored), stored);
|
|
});
|
|
|
|
test("normalizeKnownHost is a no-op when publicKey is opaque and nothing else is known", () => {
|
|
const stored: KnownHost = {
|
|
id: "kh-1",
|
|
hostname: "vps-1.example.com",
|
|
port: 22,
|
|
keyType: "unknown",
|
|
publicKey: "SHA256:already-just-a-fingerprint",
|
|
discoveredAt: 1,
|
|
};
|
|
|
|
const migrated = normalizeKnownHost(stored);
|
|
// The SHA256: prefix becomes the fingerprint; keyType stays as "unknown" since
|
|
// we cannot recover it from a bare fingerprint.
|
|
assert.equal(migrated.fingerprint, "already-just-a-fingerprint");
|
|
assert.equal(migrated.keyType, "unknown");
|
|
});
|
|
|
|
test("normalizeKnownHosts returns the same array reference when nothing needs migration", () => {
|
|
const rawKey = makeRawPublicKey("ssh-ed25519");
|
|
const fp = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
|
|
|
|
const list: KnownHost[] = [{
|
|
id: "kh-1",
|
|
hostname: "vps-1.example.com",
|
|
port: 22,
|
|
keyType: "ssh-ed25519",
|
|
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
|
|
fingerprint: fp,
|
|
discoveredAt: 1,
|
|
}];
|
|
|
|
assert.equal(normalizeKnownHosts(list), list);
|
|
});
|
|
|
|
test("normalizeKnownHosts migrates each entry that needs backfilling", () => {
|
|
const rawKeyA = makeRawPublicKey("ssh-ed25519", "host-a-key");
|
|
const rawKeyB = makeRawPublicKey("ssh-rsa", "host-b-key");
|
|
const fpA = crypto.createHash("sha256").update(rawKeyA).digest("base64").replace(/=+$/g, "");
|
|
const fpB = crypto.createHash("sha256").update(rawKeyB).digest("base64").replace(/=+$/g, "");
|
|
|
|
const list: KnownHost[] = [
|
|
{
|
|
id: "kh-1",
|
|
hostname: "vps-1.example.com",
|
|
port: 22,
|
|
keyType: "ssh-ed25519",
|
|
publicKey: `ssh-ed25519 ${rawKeyA.toString("base64")}`,
|
|
discoveredAt: 1,
|
|
},
|
|
{
|
|
id: "kh-2",
|
|
hostname: "vps-2.example.com",
|
|
port: 22,
|
|
keyType: "",
|
|
publicKey: `ssh-rsa ${rawKeyB.toString("base64")}`,
|
|
discoveredAt: 2,
|
|
},
|
|
];
|
|
|
|
const migrated = normalizeKnownHosts(list);
|
|
assert.notEqual(migrated, list);
|
|
assert.equal(migrated[0].fingerprint, fpA);
|
|
assert.equal(migrated[1].fingerprint, fpB);
|
|
assert.equal(migrated[1].keyType, "ssh-rsa");
|
|
});
|