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
394 lines
12 KiB
JavaScript
394 lines
12 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const crypto = require("node:crypto");
|
|
const path = require("node:path");
|
|
const os = require("node:os");
|
|
|
|
const { createSystemKnownHostsApi } = require("./systemKnownHosts.cjs");
|
|
|
|
// Build an api whose fs.readFileSync returns the given content for the FIRST
|
|
// system known_hosts path and throws (ENOENT-like) for the rest, mirroring the
|
|
// common case of only `~/.ssh/known_hosts` existing.
|
|
function makeApi(fileContents = {}) {
|
|
const reads = [];
|
|
const fs = {
|
|
readFileSync(filePath) {
|
|
reads.push(filePath);
|
|
if (Object.prototype.hasOwnProperty.call(fileContents, filePath)) {
|
|
return fileContents[filePath];
|
|
}
|
|
const err = new Error(`ENOENT: ${filePath}`);
|
|
err.code = "ENOENT";
|
|
throw err;
|
|
},
|
|
};
|
|
const logs = [];
|
|
const api = createSystemKnownHostsApi({
|
|
fs,
|
|
path,
|
|
os,
|
|
crypto,
|
|
log: (...args) => logs.push(args),
|
|
});
|
|
return { api, reads, logs };
|
|
}
|
|
|
|
// SHA-256 base64 (no padding) fingerprint of an OpenSSH public-key blob.
|
|
function fingerprintOf(base64Key) {
|
|
return crypto
|
|
.createHash("sha256")
|
|
.update(Buffer.from(base64Key, "base64"))
|
|
.digest("base64")
|
|
.replace(/=+$/g, "");
|
|
}
|
|
|
|
// A valid-looking ed25519 key blob seeded deterministically.
|
|
function keyBlob(seed) {
|
|
return (
|
|
"AAAAC3NzaC1lZDI1NTE5AAAAI" +
|
|
crypto.createHash("sha256").update(seed).digest("base64").slice(0, 27)
|
|
);
|
|
}
|
|
|
|
// Produce a hashed host field `|1|salt|HMAC-SHA1(salt, token)` for `token`,
|
|
// exactly as `ssh-keygen -H` would (verified empirically against ssh-keygen).
|
|
function hashedHostField(token, salt = crypto.randomBytes(20)) {
|
|
const hash = crypto.createHmac("sha1", salt).update(token).digest("base64");
|
|
return `|1|${salt.toString("base64")}|${hash}`;
|
|
}
|
|
|
|
const HOME_KH = path.join(os.homedir(), ".ssh", "known_hosts");
|
|
|
|
test("system known_hosts paths include the OpenSSH defaults for the platform", () => {
|
|
const { api } = makeApi();
|
|
const paths = api.getSystemKnownHostsPaths();
|
|
assert.ok(paths.includes(HOME_KH), "must include ~/.ssh/known_hosts");
|
|
if (process.platform === "win32") {
|
|
assert.ok(
|
|
paths.some((p) => /ssh[\\/]known_hosts$/i.test(p) && /ProgramData/i.test(p)),
|
|
"Windows must include %PROGRAMDATA%/ssh/known_hosts",
|
|
);
|
|
} else {
|
|
assert.ok(paths.includes("/etc/ssh/ssh_known_hosts"));
|
|
}
|
|
});
|
|
|
|
test("trusts a plain entry whose fingerprint matches (default port)", () => {
|
|
const blob = keyBlob("plain");
|
|
const { api } = makeApi({
|
|
[HOME_KH]: `example.com ssh-ed25519 ${blob}\n`,
|
|
});
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({
|
|
hostname: "example.com",
|
|
port: 22,
|
|
fingerprint: fingerprintOf(blob),
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("matches hostnames case-insensitively", () => {
|
|
const blob = keyBlob("case");
|
|
const { api } = makeApi({ [HOME_KH]: `Example.COM ssh-ed25519 ${blob}\n` });
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({
|
|
hostname: "example.com",
|
|
fingerprint: fingerprintOf(blob),
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("matches a comma-separated host list", () => {
|
|
const blob = keyBlob("list");
|
|
const { api } = makeApi({
|
|
[HOME_KH]: `alias.example.com,192.0.2.5 ssh-ed25519 ${blob}\n`,
|
|
});
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({
|
|
hostname: "192.0.2.5",
|
|
fingerprint: fingerprintOf(blob),
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("matches a [host]:port entry only on the right non-default port", () => {
|
|
const blob = keyBlob("port");
|
|
const { api } = makeApi({
|
|
[HOME_KH]: `[example.com]:2222 ssh-ed25519 ${blob}\n`,
|
|
});
|
|
const fingerprint = fingerprintOf(blob);
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({ hostname: "example.com", port: 2222, fingerprint }),
|
|
true,
|
|
);
|
|
// Same host, wrong port -> not trusted.
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({ hostname: "example.com", port: 22, fingerprint }),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("does NOT trust a fingerprint mismatch (different key for the same host)", () => {
|
|
const stored = keyBlob("stored");
|
|
const live = keyBlob("live-different");
|
|
const { api } = makeApi({ [HOME_KH]: `example.com ssh-ed25519 ${stored}\n` });
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({
|
|
hostname: "example.com",
|
|
fingerprint: fingerprintOf(live),
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("does NOT trust when the host does not appear at all", () => {
|
|
const blob = keyBlob("other");
|
|
const { api } = makeApi({ [HOME_KH]: `other.example.com ssh-ed25519 ${blob}\n` });
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({
|
|
hostname: "example.com",
|
|
fingerprint: fingerprintOf(blob),
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("trusts a hashed entry whose token + fingerprint match (default port)", () => {
|
|
const blob = keyBlob("hashed-default");
|
|
const { api } = makeApi({
|
|
[HOME_KH]: `${hashedHostField("example.com")} ssh-ed25519 ${blob}\n`,
|
|
});
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({
|
|
hostname: "example.com",
|
|
port: 22,
|
|
fingerprint: fingerprintOf(blob),
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("trusts a hashed entry for a non-default port ([host]:port token)", () => {
|
|
const blob = keyBlob("hashed-port");
|
|
const { api } = makeApi({
|
|
[HOME_KH]: `${hashedHostField("[h.example.com]:2022")} ssh-ed25519 ${blob}\n`,
|
|
});
|
|
const fingerprint = fingerprintOf(blob);
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({ hostname: "h.example.com", port: 2022, fingerprint }),
|
|
true,
|
|
);
|
|
// The same hashed entry must NOT match the default-port (bare-host) token.
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({ hostname: "h.example.com", port: 22, fingerprint }),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("hashed entry does not match a different hostname (HMAC differs)", () => {
|
|
const blob = keyBlob("hashed-wrong-host");
|
|
const { api } = makeApi({
|
|
[HOME_KH]: `${hashedHostField("example.com")} ssh-ed25519 ${blob}\n`,
|
|
});
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({
|
|
hostname: "evil.example.com",
|
|
fingerprint: fingerprintOf(blob),
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("matches against ssh-keygen-generated hashed entries (real fixtures)", () => {
|
|
// These two lines were produced by `ssh-keygen -H` from:
|
|
// example.com ssh-ed25519 …KEYDATA0000…
|
|
// [example.com]:2222 ssh-ed25519 …KEYDATA1111…
|
|
// and pin the exact HMAC-SHA1 hashing OpenSSH uses (incl. the bracketed
|
|
// token for the non-default port).
|
|
const blobA = "AAAAC3NzaC1lZDI1NTE5AAAAITESTKEYDATA0000000000000000000000000";
|
|
const blobB = "AAAAC3NzaC1lZDI1NTE5AAAAITESTKEYDATA1111111111111111111111111";
|
|
const content =
|
|
`|1|GjfxyrxES8V34vZje/1Lt1hHg/Y=|ZEnB8OFqAbq3mcme43V+dukJ51I= ssh-ed25519 ${blobA}\n` +
|
|
`|1|uZT6RsKBJirh9q9ycDnQUhVSmqI=|auufYDNOuFA17oSrmJwneIyl9po= ssh-ed25519 ${blobB}\n`;
|
|
const { api } = makeApi({ [HOME_KH]: content });
|
|
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({
|
|
hostname: "example.com",
|
|
port: 22,
|
|
fingerprint: fingerprintOf(blobA),
|
|
}),
|
|
true,
|
|
"default-port hashed entry must match",
|
|
);
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({
|
|
hostname: "example.com",
|
|
port: 2222,
|
|
fingerprint: fingerprintOf(blobB),
|
|
}),
|
|
true,
|
|
"non-default-port hashed entry must match",
|
|
);
|
|
// The 2222 key must NOT be accepted for port 22 (token differs).
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({
|
|
hostname: "example.com",
|
|
port: 22,
|
|
fingerprint: fingerprintOf(blobB),
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("a @revoked entry with a matching fingerprint forces NOT trusted", () => {
|
|
const blob = keyBlob("revoked");
|
|
const fingerprint = fingerprintOf(blob);
|
|
// Even if a non-revoked entry would also match, the revoked one wins.
|
|
const content =
|
|
`example.com ssh-ed25519 ${blob}\n` +
|
|
`@revoked example.com ssh-ed25519 ${blob}\n`;
|
|
const { api } = makeApi({ [HOME_KH]: content });
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint }),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("a @revoked hashed entry also forces NOT trusted", () => {
|
|
const blob = keyBlob("revoked-hashed");
|
|
const fingerprint = fingerprintOf(blob);
|
|
const content = `@revoked ${hashedHostField("example.com")} ssh-ed25519 ${blob}\n`;
|
|
const { api } = makeApi({ [HOME_KH]: content });
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint }),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("a @cert-authority line is skipped (not a literal host-key match)", () => {
|
|
const blob = keyBlob("ca");
|
|
const { api } = makeApi({
|
|
[HOME_KH]: `@cert-authority *.example.com ssh-ed25519 ${blob}\n`,
|
|
});
|
|
// Fingerprint matches the CA key, but CA delegation is not modeled -> not
|
|
// trusted via this path.
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({
|
|
hostname: "host.example.com",
|
|
fingerprint: fingerprintOf(blob),
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("comments, blank lines, and malformed lines are ignored", () => {
|
|
const blob = keyBlob("with-comments");
|
|
const content = [
|
|
"# a comment",
|
|
"",
|
|
" ",
|
|
"garbage-without-enough-fields",
|
|
`example.com ssh-ed25519 ${blob}`,
|
|
"# trailing comment",
|
|
].join("\n");
|
|
const { api } = makeApi({ [HOME_KH]: content });
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({
|
|
hostname: "example.com",
|
|
fingerprint: fingerprintOf(blob),
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("wildcard / negation host patterns are not honored for trust", () => {
|
|
const blob = keyBlob("wild");
|
|
const fingerprint = fingerprintOf(blob);
|
|
const wildcard = makeApi({ [HOME_KH]: `*.example.com ssh-ed25519 ${blob}\n` });
|
|
assert.equal(
|
|
wildcard.api.isHostKeyTrustedBySystem({ hostname: "host.example.com", fingerprint }),
|
|
false,
|
|
"a wildcard entry must not vouch for a specific host's key we never saw",
|
|
);
|
|
const negated = makeApi({
|
|
[HOME_KH]: `!example.com,example.com ssh-ed25519 ${blob}\n`,
|
|
});
|
|
assert.equal(
|
|
negated.api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint }),
|
|
true,
|
|
"the non-negated token in the list still matches",
|
|
);
|
|
});
|
|
|
|
test("combines multiple system files (home + /etc) into the trust set", () => {
|
|
const blob = keyBlob("etc");
|
|
const etcPath = process.platform === "win32"
|
|
? path.join(process.env.PROGRAMDATA || "C:\\ProgramData", "ssh", "known_hosts")
|
|
: "/etc/ssh/ssh_known_hosts";
|
|
const { api } = makeApi({
|
|
[HOME_KH]: "# only comments here\n",
|
|
[etcPath]: `shared.example.com ssh-ed25519 ${blob}\n`,
|
|
});
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({
|
|
hostname: "shared.example.com",
|
|
fingerprint: fingerprintOf(blob),
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("returns false (fail-closed) when no system files exist", () => {
|
|
const { api, reads } = makeApi(); // every read throws ENOENT
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({
|
|
hostname: "example.com",
|
|
fingerprint: "anything",
|
|
}),
|
|
false,
|
|
);
|
|
assert.ok(reads.length >= 1, "should have attempted to read at least one path");
|
|
});
|
|
|
|
test("returns false on empty/whitespace fingerprint or hostname", () => {
|
|
const blob = keyBlob("guard");
|
|
const { api } = makeApi({ [HOME_KH]: `example.com ssh-ed25519 ${blob}\n` });
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({ hostname: "", fingerprint: fingerprintOf(blob) }),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: "" }),
|
|
false,
|
|
);
|
|
assert.equal(api.isHostKeyTrustedBySystem({}), false);
|
|
});
|
|
|
|
test("fingerprint comparison ignores base64 padding differences", () => {
|
|
const blob = keyBlob("padding");
|
|
const { api } = makeApi({ [HOME_KH]: `example.com ssh-ed25519 ${blob}\n` });
|
|
const padded = `${fingerprintOf(blob)}==`;
|
|
assert.equal(
|
|
api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: padded }),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("parseKnownHostsLine extracts markers and fingerprint", () => {
|
|
const blob = keyBlob("parse-line");
|
|
const { api } = makeApi();
|
|
const entry = api.parseKnownHostsLine(`@revoked example.com ssh-rsa ${blob}`);
|
|
assert.equal(entry.revoked, true);
|
|
assert.equal(entry.certAuthority, false);
|
|
assert.equal(entry.hostField, "example.com");
|
|
assert.equal(entry.keyType, "ssh-rsa");
|
|
assert.equal(entry.fingerprint, fingerprintOf(blob));
|
|
assert.equal(api.parseKnownHostsLine("# comment"), null);
|
|
assert.equal(api.parseKnownHostsLine(""), null);
|
|
assert.equal(api.parseKnownHostsLine("too few"), null);
|
|
});
|