[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:
393
electron/bridges/sshBridge/systemKnownHosts.test.cjs
Normal file
393
electron/bridges/sshBridge/systemKnownHosts.test.cjs
Normal file
@@ -0,0 +1,393 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user