Files
Netcatty/domain/knownHosts.test.ts
陈大猫 86e47b5f9e Fix #972: stop false "fingerprint changed" warnings on every SSH connect (#973)
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>
2026-05-13 11:41:36 +08:00

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");
});