Compare commits

..
Author SHA1 Message Date
Codex 3e015a3b7c fix(ssh): fingerprint OpenSSH host certs by the underlying key
Fixes #4222

describeHostKey previously hashed the entire certificate blob, so load-balanced or re-issued certs for the same host key looked like a fingerprint change. Pin the underlying public key instead.

Share extraction across verifier, renderer migration, and system known_hosts. Migrate legacy full-cert fingerprints only when the stored certificate body is available, preserving certificate metadata and raw rekey checks.
2026-11-12 23:20:48 +08:00
12 changed files with 360 additions and 74 deletions

No files matched your search

+54
View File
@@ -1,6 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { describeHostKey } = require("../electron/bridges/hostKeyVerifier.cjs");
const { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip } = require("../electron/bridges/fixtures/hostCertificates.test.fixture.cjs");
import type { KnownHost } from "./models";
import {
@@ -139,6 +144,55 @@ test("fingerprintFromPublicKey returns empty string on missing input", () => {
// --- Migration --------------------------------------------------------------
for (const options of [
{ type: "ed25519" },
{ type: "rsa", bits: 2048 },
...[256, 384, 521].map((bits) => ({ type: "ecdsa", bits })),
{ type: "ed25519", sk: true },
{ type: "ecdsa", bits: 256, sk: true },
]) {
test(`renderer certificate fingerprint and migration agree with verifier: ${JSON.stringify(options)}`, { skip: sshKeygenSkip }, (t) => {
const { first, renewed, different } = mintHostCertificates(t, options);
const expected = describeHostKey(first.blob).fingerprint;
assert.equal(fingerprintFromPublicKey(first.line), expected);
assert.equal(expected, first.publicKeyFingerprint);
assert.equal(fingerprintFromPublicKey(renewed.line), expected);
assert.notEqual(fingerprintFromPublicKey(different.line), expected);
assert.equal(fingerprintFromPublicKey(first.plain.line), expected);
const legacy = knownHost({
keyType: first.keyType,
publicKey: first.line,
fingerprint: crypto.createHash("sha256").update(first.blob).digest("base64").replace(/=+$/g, ""),
});
const migrated = normalizeKnownHost(legacy);
assert.equal(migrated.fingerprint, expected);
assert.equal(migrated.keyType, first.keyType);
assert.equal(migrated.publicKey, first.line);
assert.equal(normalizeKnownHost(migrated), migrated, "migration is idempotent");
assert.equal(normalizeKnownHost({ ...legacy, fingerprint: undefined }).fingerprint, expected);
assert.equal(normalizeKnownHost({ ...legacy, keyType: "" }).keyType, first.keyType);
const unrelated = { ...legacy, fingerprint: "unrelated-explicit-fingerprint" };
assert.equal(normalizeKnownHost(unrelated), unrelated);
const opaque = { ...legacy, publicKey: `SHA256:${legacy.fingerprint}` };
assert.equal(normalizeKnownHost(opaque), opaque);
const fingerprintOnly = { ...legacy, publicKey: undefined };
assert.equal(normalizeKnownHost(fingerprintOnly), fingerprintOnly);
});
}
test("renderer fingerprints v00 certificates and falls back for unparseable certificates", () => {
const cert = legacyCertificate("ssh-rsa", ["exponent", "modulus"]);
assert.equal(fingerprintFromPublicKey(cert.line), describeHostKey(cert.plain).fingerprint);
const malformed = Buffer.concat([
sshString("ssh-ed25519-cert-v01@openssh.com"), sshString("nonce"), sshString(Buffer.alloc(32)),
]);
const line = `ssh-ed25519-cert-v01@openssh.com ${malformed.toString("base64")}`;
const fullHash = crypto.createHash("sha256").update(malformed).digest("base64").replace(/=+$/g, "");
assert.equal(fingerprintFromPublicKey(line), fullHash);
assert.equal(fingerprintFromPublicKey(line), describeHostKey(malformed).fingerprint);
});
test("normalizeKnownHost backfills fingerprint when only publicKey is stored", () => {
const rawKey = makeRawPublicKey("ssh-ed25519");
const base64Body = rawKey.toString("base64");
+15 -6
View File
@@ -1,4 +1,5 @@
import type { KnownHost } from "./models";
import { extractCertificatePublicKey } from "@/domain/sshPublicKey.shared.cjs";
const normalizeHost = (value: string) => value.trim().toLowerCase();
@@ -132,7 +133,7 @@ export const fingerprintFromPublicKey = (publicKey: string | undefined | null):
const parts = trimmed.split(/\s+/);
if (parts.length >= 2 && SSH_KEY_TYPE_PREFIX.test(parts[0])) {
const bytes = base64Decode(parts[1]);
if (bytes) return stripPadding(base64Encode(sha256Bytes(bytes)));
if (bytes) return stripPadding(base64Encode(sha256Bytes(extractCertificatePublicKey(bytes) || bytes)));
}
return stripPadding(trimmed);
@@ -145,9 +146,9 @@ const extractKeyTypeFromPublicKey = (publicKey: string | undefined | null): stri
};
/**
* Backfill missing `fingerprint` / `keyType` on a stored record so the host
* verifier can match it without falling back to the brittle re-derivation
* path. Returns the same reference when nothing changes so callers can skip
* Backfill missing `fingerprint` / `keyType` and migrate full-certificate
* fingerprints to the underlying key when the certificate body is available.
* Returns the same reference when nothing changes so callers can skip
* persistence writes and React re-renders.
*/
export const normalizeKnownHost = (knownHost: KnownHost): KnownHost => {
@@ -156,9 +157,17 @@ export const normalizeKnownHost = (knownHost: KnownHost): KnownHost => {
&& knownHost.keyType.length > 0
&& knownHost.keyType !== "unknown";
if (hasFingerprint && hasKeyType) return knownHost;
const parts = knownHost.publicKey?.trim().split(/\s+/) ?? [];
const certBytes = /-cert-v0[01]@openssh\.com$/.test(parts[0]) && parts[1]
? base64Decode(parts[1]) : null;
const legacyCertFingerprint = certBytes && extractCertificatePublicKey(certBytes)
? stripPadding(base64Encode(sha256Bytes(certBytes))) : "";
const migrateCertificate = legacyCertFingerprint
&& stripPadding((knownHost.fingerprint ?? "").replace(/^SHA256:/i, "")) === legacyCertFingerprint;
const derivedFingerprint = hasFingerprint
if (hasFingerprint && hasKeyType && !migrateCertificate) return knownHost;
const derivedFingerprint = hasFingerprint && !migrateCertificate
? knownHost.fingerprint!
: fingerprintFromPublicKey(knownHost.publicKey);
const derivedKeyType = hasKeyType
+84
View File
@@ -0,0 +1,84 @@
"use strict";
// Extract only the public-key wire blob for fingerprinting, never CA trust or
// certificate validation. ssh2's getPublicSSH() preserves the full certificate,
// and its internal base-type helper is not exported. Keep this byte-only helper
// shared by Electron and the renderer so migration uses exactly the same bytes.
// Layouts: OpenSSH PROTOCOL.certkeys (v00/v01) and PROTOCOL.u2f (SK).
function extractCertificatePublicKey(blob) {
let offset = 0;
const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength);
const skip = (length) => {
if (offset + length > blob.length) throw new Error("Truncated SSH key");
offset += length;
};
const readString = () => {
skip(4);
const length = view.getUint32(offset - 4, false);
const start = offset;
skip(length);
return blob.subarray(start, offset);
};
const ascii = (bytes) => String.fromCharCode(...bytes);
try {
const typeBytes = readString();
if (typeBytes.length > 128) return null;
const match = ascii(typeBytes).match(/^(.*)-cert-v0([01])@openssh\.com$/);
if (!match) return null;
let baseType = match[1];
const v01 = match[2] === "1";
if (/^rsa-sha2-(256|512)$/.test(baseType)) baseType = "ssh-rsa";
if (baseType === "sk-ssh-ed25519" || baseType === "sk-ecdsa-sha2-nistp256") {
baseType += "@openssh.com";
}
let fields;
switch (baseType) {
case "ssh-rsa": fields = 2; break;
case "ssh-dss": fields = 4; break;
case "ssh-ed25519": fields = 1; break;
case "sk-ssh-ed25519@openssh.com": fields = 2; break;
case "sk-ecdsa-sha2-nistp256@openssh.com": fields = 3; break;
case "ecdsa-sha2-nistp256":
case "ecdsa-sha2-nistp384":
case "ecdsa-sha2-nistp521": fields = 2; break;
default: return null;
}
// v00 put its nonce after the constraints, not before the key material.
if (v01) readString();
const keyStart = offset;
for (let i = 0; i < fields; i += 1) {
const field = readString();
const isApplication = baseType.startsWith("sk-") && i === fields - 1;
if (!field.length && !isApplication) return null;
if (i === 0 && baseType.includes("ed25519") && field.length !== 32) return null;
if (i === 0 && baseType.includes("ecdsa")) {
const curve = baseType.replace(/^sk-/, "").replace(/@openssh\.com$/, "").replace(/^ecdsa-sha2-/, "");
if (field.length > 8 || ascii(field) !== curve) return null;
}
}
const keyEnd = offset;
// Check the outer envelope too; truncated/unsupported certificates retain
// their full-blob fingerprint rather than gaining an inner-key match.
if (v01) skip(8); // serial
skip(4); // host/user type
readString(); // key ID
readString(); // principals
skip(16); // validity
readString(); // critical options (v00 constraints)
readString(); // extensions (v00 nonce)
readString(); // reserved
if (!readString().length || !readString().length || offset !== blob.length) return null;
const result = new Uint8Array(4 + baseType.length + keyEnd - keyStart);
new DataView(result.buffer).setUint32(0, baseType.length, false);
for (let i = 0; i < baseType.length; i += 1) result[4 + i] = baseType.charCodeAt(i);
result.set(blob.subarray(keyStart, keyEnd), 4 + baseType.length);
return result;
} catch {
return null;
}
}
exports.extractCertificatePublicKey = extractCertificatePublicKey;
+1
View File
@@ -84,6 +84,7 @@ module.exports = {
// is excluded below, so the shared TOTP runtime must be listed.
'domain/totp.shared.cjs',
'domain/totpPrompt.shared.cjs',
'domain/sshPublicKey.shared.cjs',
'lib/**/*.cjs',
'lib/**/*.json',
'skills/**/*',
@@ -24,21 +24,6 @@ const UI_DISALLOWED_TOOLS = ["AskUserQuestion"];
const MCP_MODE_TOOLS = [];
const SKILLS_MODE_TOOLS = ["Bash"];
// Deduped built-in catalog from @tencent-ai/agent-sdk 0.3.258 cli/product.json.
const CODEBUDDY_BUILTIN_TOOLS = [
"Agent", "Bash", "PowerShell", "Glob", "Grep", "Read", "Edit", "Write",
"NotebookEdit", "WebFetch", "ListMcpResources", "ReadMcpResource", "WaitForMcpServers",
"TaskCreate", "TaskGet", "TaskUpdate", "TaskList", "WebSearch", "EnterPlanMode",
"ExitPlanMode", "KillShell", "TaskStop", "TaskOutput", "SlashCommand", "Skill",
"SkillManage", "AskUserQuestion", "AskUserForStructuredInput", "LSP", "StructuredOutput",
"ToolSearch", "DeferExecuteTool", "ImageGen", "ImageEdit", "VideoGen", "Artifact",
"ArtifactControl", "ComputerUse", "TeamCreate", "TeamDelete", "SendMessage",
"SendUserMessage", "EnterWorktree", "LeaveWorktree", "CronCreate", "CronDelete",
"CronList", "DelegateTool", "WeChatReply", "WeComReply", "PushNotification",
"ReportFindings", "Workflow", "Monitor", "REPL", "A2AGetAgentCard", "A2ASendMessage",
"A2AGetTask", "A2ACancelTask",
];
const CODEBUDDY_IMAGE_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
function isCodebuddyImageAttachment(attachment) {
@@ -276,19 +261,18 @@ function buildCodebuddyQueryOptions({
allowDangerouslySkipPermissions: true,
extraArgs: { "dangerously-skip-permissions": null },
mcpServers: toSdkMcpServers(injectedMcpServers),
// Block built-ins explicitly in mcp mode: tools: [] becomes --tools "",
// which also hides injected MCP tools in CodeBuddy CLI v2.183.0.
// Leave allowedTools unset: an empty auto-approval list prevents MCP tool
// calls from running under bypassPermissions.
disallowedTools: builtinTools.length > 0
? [...UI_DISALLOWED_TOOLS]
: [...new Set([...UI_DISALLOWED_TOOLS, ...CODEBUDDY_BUILTIN_TOOLS])],
tools: builtinTools,
// `tools` is the built-in tool whitelist. In mcp mode it is [], so CodeBuddy
// built-ins stay disabled while injected Netcatty MCP tools remain visible.
// Do not mirror that empty list into allowedTools: the SDK treats
// allowedTools as an auto-approval list, and allowedTools: [] prevents MCP
// tool calls from running under bypassPermissions.
disallowedTools: [...UI_DISALLOWED_TOOLS],
// Keep the SDK isolated from user/project settings so local hooks, plugins,
// or extra MCP servers cannot expand Netcatty's controlled tool boundary.
settingSources: [],
env,
};
if (builtinTools.length > 0) options.tools = builtinTools;
const selection = splitCodebuddyModelSelection(model);
if (selection.model) options.model = selection.model;
if (abortController) options.abortController = abortController;
@@ -59,51 +59,15 @@ test("buildCodebuddyQueryOptions wires SDK options in isolated mode", () => {
assert.equal(opts.pathToCodebuddyCode, "/opt/codebuddy/bin/codebuddy");
assert.equal(opts.abortController, ac);
assert.equal(opts.resume, "sess-1");
assert.ok(!("tools" in opts));
assert.deepEqual(opts.tools, []);
// allowedTools must stay unset in mcp mode: tools:[] disables built-ins, while
// allowedTools:[] would prevent injected Netcatty MCP tools from running.
assert.ok(!("allowedTools" in opts));
assert.ok(opts.disallowedTools.includes("Bash"));
assert.ok(opts.disallowedTools.includes("AskUserQuestion"));
assert.equal(opts.mcpServers["netcatty-remote-hosts"].type, "stdio");
assert.deepEqual(opts.mcpServers["netcatty-remote-hosts"].env, { NETCATTY_MCP_PORT: "1" });
});
test("mcp and default mode block built-ins while preserving injected MCP servers", () => {
for (const toolIntegrationMode of ["mcp", undefined]) {
const opts = buildCodebuddyQueryOptions({
toolIntegrationMode,
injectedMcpServers: [{ name: "netcatty", command: "/abs/electron" }],
});
assert.ok(!("tools" in opts));
assert.ok(!("allowedTools" in opts));
assert.equal(opts.permissionMode, "bypassPermissions");
for (const name of ["Bash", "PowerShell", "Read", "Edit", "Write", "Agent", "Skill", "AskUserQuestion"]) {
assert.ok(opts.disallowedTools.includes(name), `${name} must stay disabled`);
}
assert.equal(opts.mcpServers.netcatty.command, "/abs/electron");
}
});
test("mcp mode omits --tools under the SDK serialization rule", () => {
for (const toolIntegrationMode of ["mcp", undefined]) {
const opts = buildCodebuddyQueryOptions({ toolIntegrationMode });
const args = [];
// @tencent-ai/agent-sdk lib/transport/process-transport.js
if (opts.tools !== undefined) {
args.push("--tools", opts.tools.join(","));
}
assert.deepEqual(args, []);
}
});
test("skills mode keeps the Bash whitelist and UI exclusions", () => {
const opts = buildCodebuddyQueryOptions({ toolIntegrationMode: "skills" });
assert.deepEqual(opts.tools, ["Bash"]);
assert.deepEqual(opts.disallowedTools, ["AskUserQuestion"]);
assert.ok(!opts.disallowedTools.includes("Bash"));
assert.ok(!("allowedTools" in opts));
});
test("built-in tools are mode-aware", () => {
assert.deepEqual(codebuddyBuiltinTools("mcp"), []);
assert.deepEqual(codebuddyBuiltinTools(undefined), []);
@@ -0,0 +1,76 @@
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
let sshKeygenSkip = false;
try {
execFileSync("ssh-keygen", ["-h"], { stdio: "ignore" });
} catch (error) {
if (error.code === "ENOENT") sshKeygenSkip = "ssh-keygen is not installed";
}
const sshString = (value) => {
const body = Buffer.from(value);
const length = Buffer.alloc(4);
length.writeUInt32BE(body.length);
return Buffer.concat([length, body]);
};
// Real local certificates, with no server/network or security-key device.
function mintHostCertificates(t, { type = "ed25519", bits, sk = false, user = false } = {}) {
const dir = fs.mkdtempSync(path.join(__dirname, ".hostcert-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const keygen = (...args) => execFileSync("ssh-keygen", ["-q", ...args], { stdio: "pipe" });
const ca = path.join(dir, "ca");
keygen("-t", "ed25519", "-N", "", "-f", ca);
const readKey = (file) => {
const line = fs.readFileSync(file, "utf8").trim();
const [keyType, body] = line.split(/\s+/);
return { line, keyType, blob: Buffer.from(body, "base64") };
};
const mint = (name, validity, serial) => {
const host = path.join(dir, name);
if (!fs.existsSync(host)) {
keygen("-t", type, ...(bits ? ["-b", String(bits)] : []), "-N", "", "-f", host);
if (sk) {
const plain = readKey(`${host}.pub`);
const keyMaterial = plain.blob.subarray(4 + plain.keyType.length);
const skType = `sk-${plain.keyType}@openssh.com`;
const blob = Buffer.concat([sshString(skType), keyMaterial, sshString("ssh:netcatty-test")]);
fs.writeFileSync(`${host}.pub`, `${skType} ${blob.toString("base64")}\n`);
}
}
keygen("-s", ca, "-I", "netcatty-hostcert", ...(user ? [] : ["-h"]),
"-n", "127.0.0.1", "-V", validity, "-z", String(serial), `${host}.pub`);
const cert = readKey(`${host}-cert.pub`);
const listing = execFileSync("ssh-keygen", ["-L", "-f", `${host}-cert.pub`], { encoding: "utf8" });
const publicKeyFingerprint = listing.match(/Public key: .* SHA256:(\S+)/)?.[1];
if (!publicKeyFingerprint) throw new Error("Missing ssh-keygen certificate fingerprint");
return { ...cert, publicKeyFingerprint, plain: readKey(`${host}.pub`) };
};
return {
first: mint("host", "20250101:20260101", 1),
renewed: mint("host", "20260101:20270101", 2),
different: mint("other", "20260101:20270101", 3),
};
}
// Legacy v00 is no longer emitted by ssh-keygen. This follows the original
// PROTOCOL.certkeys layout: key material first, nonce after constraints.
function legacyCertificate(keyType, keyFields) {
const blob = Buffer.concat([
sshString(`${keyType}-cert-v00@openssh.com`),
...keyFields.map(sshString),
Buffer.from([0, 0, 0, 2]),
sshString("legacy"), sshString(""), Buffer.alloc(16),
sshString(""), sshString("nonce"), sshString(""),
sshString("signing key"), sshString("signature"),
]);
return {
blob,
plain: Buffer.concat([sshString(keyType), ...keyFields.map(sshString)]),
line: `${keyType}-cert-v00@openssh.com ${blob.toString("base64")}`,
};
}
module.exports = { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip };
+17 -4
View File
@@ -1,6 +1,7 @@
const crypto = require("node:crypto");
const { randomUUID } = require("node:crypto");
const { utils: sshUtils } = require("ssh2");
const { extractCertificatePublicKey } = require("../../domain/sshPublicKey.shared.cjs");
const REQUEST_TTL_MS = 2 * 60 * 1000;
const hostKeyRequests = new Map();
@@ -65,8 +66,9 @@ const fingerprintFromPublicKey = (publicKey) => {
const parts = trimmed.split(/\s+/);
if (parts.length >= 2 && /^ssh-|^ecdsa-|^sk-/.test(parts[0])) {
try {
const blob = Buffer.from(parts[1], "base64");
return crypto.createHash("sha256")
.update(Buffer.from(parts[1], "base64"))
.update(extractCertificatePublicKey(blob) || blob)
.digest("base64")
.replace(/=+$/g, "");
} catch {
@@ -77,7 +79,16 @@ const fingerprintFromPublicKey = (publicKey) => {
};
const getKnownHostFingerprint = (knownHost) => {
return normalizeFingerprint(knownHost?.fingerprint)
const stored = normalizeFingerprint(knownHost?.fingerprint);
const parts = knownHost?.publicKey?.trim().split(/\s+/) || [];
if (parts.length >= 2 && /-cert-v0[01]@openssh\.com$/.test(parts[0])) {
const blob = Buffer.from(parts[1], "base64");
// Only replace an explicit fingerprint if it is the legacy full-cert hash.
// Fingerprint-only records cannot be migrated without the certificate body.
const legacy = crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/g, "");
if (!stored || stored === legacy) return fingerprintFromPublicKey(knownHost.publicKey);
}
return stored
|| fingerprintFromPublicKey(knownHost?.publicKey);
};
@@ -134,7 +145,7 @@ const classifyHostKey = ({ knownHosts = [], hostname, port = 22, keyType, finger
const describeHostKey = (rawKey) => {
const key = Buffer.isBuffer(rawKey) ? rawKey : Buffer.from(rawKey || "");
const fingerprint = crypto.createHash("sha256")
.update(key)
.update(extractCertificatePublicKey(key) || key)
.digest("base64")
.replace(/=+$/g, "");
let keyType = "unknown";
@@ -149,7 +160,9 @@ const describeHostKey = (rawKey) => {
try {
const parsed = sshUtils.parseKey(key);
const parsedKey = Array.isArray(parsed) ? parsed[0] : parsed;
if (parsedKey && !(parsedKey instanceof Error)) {
// Keep the presented certificate identity, even if a parser returns its
// base algorithm or underlying public key as metadata.
if (parsedKey && !(parsedKey instanceof Error) && !/-cert-v0[01]@openssh\.com$/.test(keyType)) {
keyType = parsedKey.type || keyType;
const publicSsh = parsedKey.getPublicSSH?.();
if (publicSsh) publicKey = publicSsh.toString("utf8");
+80
View File
@@ -1,11 +1,13 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip } = require("./fixtures/hostCertificates.test.fixture.cjs");
const {
classifyHostKey,
createHostVerifier,
describeHostKey,
getKnownHostFingerprint,
handleResponse,
normalizeFingerprint,
} = require("./hostKeyVerifier.cjs");
@@ -17,6 +19,84 @@ const makeRawPublicKey = (keyType, body = "trusted imported host key") => {
return Buffer.concat([length, type, Buffer.from(body)]);
};
const hashKey = (blob) => crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/g, "");
test("host certificate renewals trust the inner key, including legacy saved fingerprints", { skip: sshKeygenSkip }, (t) => {
const { first, renewed, different } = mintHostCertificates(t);
const original = describeHostKey(first.blob);
const live = describeHostKey(renewed.blob);
const changed = describeHostKey(different.blob);
assert.notDeepEqual(first.blob, renewed.blob);
assert.equal(original.fingerprint, first.publicKeyFingerprint);
assert.equal(live.fingerprint, original.fingerprint);
assert.notEqual(changed.fingerprint, live.fingerprint);
assert.equal(original.keyType, "ssh-ed25519-cert-v01@openssh.com");
assert.equal(original.publicKey, `${first.keyType} ${first.blob.toString("base64")}`);
assert.equal(describeHostKey(first.plain.blob).fingerprint, hashKey(first.plain.blob));
const stored = { id: "kh-cert", hostname: "cert.local", port: 2222, ...original };
const classify = (key, record = stored) => classifyHostKey({
knownHosts: [record], hostname: "cert.local", port: 2222, ...key,
});
assert.equal(classify(live).status, "trusted");
assert.equal(classify(changed).status, "changed");
assert.equal(classify(changed, { ...stored, keyType: "ssh-rsa" }).status, "unknown");
const legacy = { ...stored, fingerprint: hashKey(first.blob) };
assert.notEqual(legacy.fingerprint, live.fingerprint);
assert.equal(getKnownHostFingerprint(legacy), live.fingerprint);
assert.equal(classify(live, legacy).status, "trusted");
assert.equal(classify(changed, legacy).status, "changed");
assert.equal(classify(live, { ...legacy, publicKey: undefined }).status, "changed");
assert.equal(classify(live, { ...legacy, publicKey: `SHA256:${legacy.fingerprint}` }).status, "changed");
assert.equal(classify(live, { ...legacy, fingerprint: "unrelated-explicit-fingerprint" }).status, "changed");
assert.equal(classify(live, { ...legacy, fingerprint: undefined }).status, "trusted");
// Renewal is trusted on reconnect, but changing the raw certificate during
// an established connection must still be rejected synchronously on rekey.
const verifier = createHostVerifier({ hostname: stored.hostname, port: stored.port, knownHosts: [legacy] });
const results = [];
verifier(first.blob, (accepted) => results.push(accepted));
verifier(Buffer.from(first.blob), (accepted) => results.push(accepted));
verifier(renewed.blob, (accepted) => results.push(accepted));
assert.deepEqual(results, [true, true, false]);
});
for (const options of [
{ type: "rsa", bits: 2048 },
...[256, 384, 521].map((bits) => ({ type: "ecdsa", bits })),
{ type: "ed25519", sk: true },
{ type: "ecdsa", bits: 256, sk: true },
{ type: "ed25519", user: true },
]) {
test(`certificate fingerprint matches ssh-keygen for ${JSON.stringify(options)}`, { skip: sshKeygenSkip }, (t) => {
const { first, renewed, different } = mintHostCertificates(t, options);
assert.equal(describeHostKey(first.blob).fingerprint, first.publicKeyFingerprint);
assert.equal(describeHostKey(first.blob).fingerprint, hashKey(first.plain.blob));
assert.equal(describeHostKey(renewed.blob).fingerprint, first.publicKeyFingerprint);
assert.notEqual(describeHostKey(different.blob).fingerprint, first.publicKeyFingerprint);
});
}
test("v00 certificates extract the key before the nonce", () => {
for (const [type, fields] of [["ssh-rsa", ["exponent", "modulus"]], ["ssh-dss", ["p", "q", "g", "y"]]]) {
const cert = legacyCertificate(type, fields);
assert.equal(describeHostKey(cert.blob).fingerprint, hashKey(cert.plain));
assert.equal(describeHostKey(cert.blob).keyType, `${type}-cert-v00@openssh.com`);
}
});
test("malformed and unsupported certificates keep their full-blob fingerprint", () => {
const truncated = Buffer.concat([
sshString("ssh-ed25519-cert-v01@openssh.com"), sshString("nonce"), sshString(Buffer.alloc(32)),
]);
const badLength = Buffer.concat([sshString("ssh-ed25519-cert-v01@openssh.com"), Buffer.from([255, 255, 255, 255])]);
const unsupported = Buffer.concat([sshString("unknown-cert-v01@openssh.com"), sshString("nonce")]);
for (const blob of [truncated, badLength, unsupported]) {
assert.equal(describeHostKey(blob).fingerprint, hashKey(blob));
}
});
test("classifyHostKey prompts for unknown hosts", () => {
const result = classifyHostKey({
knownHosts: [],
@@ -41,6 +41,8 @@
* leaves the Mosh stats bar empty (graceful degradation), never weakens
* security.
*/
const { extractCertificatePublicKey } = require("../../../domain/sshPublicKey.shared.cjs");
function createSystemKnownHostsApi(ctx) {
const { fs, path, os, crypto, log } = ctx;
@@ -64,7 +66,7 @@ function createSystemKnownHostsApi(ctx) {
}
if (blob.length === 0) return "";
return stripFingerprintPadding(
crypto.createHash("sha256").update(blob).digest("base64"),
crypto.createHash("sha256").update(extractCertificatePublicKey(blob) || blob).digest("base64"),
);
};
@@ -176,7 +178,7 @@ function createSystemKnownHostsApi(ctx) {
// Parse one known_hosts line into { revoked, certAuthority, hostField,
// keyType, fingerprint } or null when it is a comment / blank / malformed.
// The fingerprint is the SHA-256 of the line's key blob.
// Certificates pin their underlying key, just like the live verifier.
const parseKnownHostsLine = (rawLine) => {
const line = String(rawLine || "").trim();
if (!line || line.startsWith("#")) return null;
@@ -1,6 +1,8 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { mintHostCertificates, sshKeygenSkip } = require("../fixtures/hostCertificates.test.fixture.cjs");
const { describeHostKey } = require("../hostKeyVerifier.cjs");
const path = require("node:path");
const os = require("node:os");
@@ -59,6 +61,22 @@ function hashedHostField(token, salt = crypto.randomBytes(20)) {
const HOME_KH = path.join(os.homedir(), ".ssh", "known_hosts");
test("system certificate entries pin the inner key across renewals without CA trust", { skip: sshKeygenSkip }, (t) => {
const { first, renewed, different } = mintHostCertificates(t);
const { api } = makeApi({ [HOME_KH]: `example.com ${first.line}\n` });
const liveFingerprint = describeHostKey(renewed.blob).fingerprint;
assert.equal(api.parseKnownHostsLine(`example.com ${first.line}`).fingerprint, liveFingerprint);
assert.equal(api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), true);
assert.equal(api.isHostKeyTrustedBySystem({
hostname: "example.com", fingerprint: describeHostKey(different.blob).fingerprint,
}), false);
const revoked = makeApi({ [HOME_KH]: `example.com ${first.line}\n@revoked example.com ${renewed.line}\n` });
assert.equal(revoked.api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), false);
const ca = makeApi({ [HOME_KH]: `@cert-authority example.com ${first.line}\n` });
assert.equal(ca.api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), false);
});
test("system known_hosts paths include the OpenSSH defaults for the platform", () => {
const { api } = makeApi();
const paths = api.getSystemKnownHostsPaths();
+2 -1
View File
@@ -111,7 +111,7 @@ export default defineConfig(() => {
build: {
// The shared runtime is also required by Electron; convert it for browser builds.
commonjsOptions: {
include: [/node_modules/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions|ThemePack|RemoteTreePolicy|ViewStylePolicy|GitHubRepository)\.cjs$/],
include: [/node_modules/, /domain\/sshPublicKey\.shared\.cjs$/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions|ThemePack|RemoteTreePolicy|ViewStylePolicy|GitHubRepository)\.cjs$/],
},
chunkSizeWarningLimit: 3000,
target: 'esnext', // Required for top-level await in WASM modules
@@ -134,6 +134,7 @@ export default defineConfig(() => {
include: [
// Keep the renderer alias pre-bundled so dev serves ESM instead of raw CommonJS.
'@/domain/systemManager/containerRuntime.shared.cjs',
'@/domain/sshPublicKey.shared.cjs',
'@/domain/pluginRuntimePolicy.cjs',
'@/domain/pluginGitHubRepository.cjs',
'@/domain/pluginPermissionDescriptions.cjs',