Compare commits

...

1 Commits

Author SHA1 Message Date
diegosouzapw
df1d8cefc9 fix(mitm): resolve AgentBridge cert-trust check against the active cert model (#14070)
AgentBridge's cert-trust check (state/route.ts, diagnose/route.ts, and the
trust-cert action in server/route.ts) hard-coded the legacy leaf server.crt
for the OS-trust-store lookup regardless of which cert model was actually
active. manager.ts's own decideCertMigration()/startMitmInternal() switches
to the persisted root CA (ca.crt) whenever both a legacy leaf and a CA pair
exist on disk, so the dashboard permanently reported "Certificate not
trusted" even though ca.crt was correctly installed and trusted by the OS.

Adds src/mitm/cert/activeCert.ts::resolveActiveCertPath() as the single
source of truth for which cert file is active for this run, reused by
manager.ts and all three AgentBridge route handlers. The trust-cert manual
action now also calls installCaCert() instead of always installCertResult()
when root-CA mode is active.
2026-09-21 22:08:49 -03:00
7 changed files with 199 additions and 10 deletions

View File

@@ -0,0 +1 @@
- fix(mitm): resolve AgentBridge cert-trust check against the active cert model instead of hard-coded server.crt (#14070)

View File

@@ -19,6 +19,7 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { getMitmStatus } from "@/mitm/manager";
import { checkCertInstalled } from "@/mitm/cert/install";
import { resolveActiveCertPath } from "@/mitm/cert/activeCert";
import { resolveMitmDataDir } from "@/mitm/dataDir";
import { summarizeDiagnostics } from "@/mitm/inspector/diagnostics";
import { getAllAgentBridgeStates } from "@/lib/db/agentBridgeState";
@@ -46,7 +47,12 @@ export async function GET(request: Request): Promise<Response> {
try {
const agentId = new URL(request.url).searchParams.get("agentId") ?? undefined;
const status = await getMitmStatus(agentId);
const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
// #14070: resolve the file the active migration decision actually
// installs (ca.crt under the root-CA model) instead of always
// hard-coding the legacy server.crt path.
const certDir = path.join(resolveMitmDataDir(), "mitm");
const rootCaEnabled = process.env.MITM_ROOT_CA_ENABLED === "true";
const { certPath } = resolveActiveCertPath(certDir, rootCaEnabled);
const certExists = fs.existsSync(certPath);
const certTrusted = certExists ? await checkCertInstalled(certPath) : false;
const port =

View File

@@ -7,8 +7,9 @@
*/
import { AgentBridgeServerActionSchema } from "@/shared/schemas/agentBridge";
import { getCachedPassword, setCachedPassword } from "@/mitm/manager";
import { installCertResult, checkCertInstalled } from "@/mitm/cert/install";
import { installCertResult, installCaCert, checkCertInstalled } from "@/mitm/cert/install";
import { generateCert } from "@/mitm/cert/generate";
import { resolveActiveCertPath } from "@/mitm/cert/activeCert";
import { resolveMitmDataDir } from "@/mitm/dataDir";
import {
isMitmSudoPasswordRequired,
@@ -104,8 +105,17 @@ export async function POST(request: Request): Promise<Response> {
if (isMitmSudoPasswordRequired(sudoPassword)) {
return createErrorResponse({ status: 400, message: "Missing sudoPassword" });
}
const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
const result = await installCertResult(sudoPassword, certPath);
// #14070: resolve + trust the file the active migration decision
// actually installs (ca.crt via installCaCert() under the root-CA
// model) instead of always hard-coding/trusting the legacy
// server.crt — mirrors manager.ts's own branch (startMitmInternal).
const certDir = path.join(resolveMitmDataDir(), "mitm");
const rootCaEnabled = process.env.MITM_ROOT_CA_ENABLED === "true";
const { certPath, mode } = resolveActiveCertPath(certDir, rootCaEnabled);
const result =
mode === "use-root-ca"
? await installCaCert(sudoPassword, certPath)
: await installCertResult(sudoPassword, certPath);
if (result.installed) {
const suppliedPassword =
typeof raw.sudoPassword === "string"

View File

@@ -15,6 +15,7 @@ import { getAllAgentBridgeStates } from "@/lib/db/agentBridgeState";
import { getAllBypassPatterns } from "@/lib/db/agentBridgeBypass";
import { getMappingsForAgent } from "@/lib/db/agentBridgeMappings";
import { checkCertInstalled } from "@/mitm/cert/install";
import { resolveActiveCertPath } from "@/mitm/cert/activeCert";
import { resolveMitmDataDir } from "@/mitm/dataDir";
import { ALL_TARGETS } from "@/mitm/targets/index";
import path from "path";
@@ -42,9 +43,13 @@ export async function GET(): Promise<Response> {
);
const mappings = Object.fromEntries(mappingsEntries);
// Compute REAL certTrusted (OS trust store check, not just file exists)
// Compute REAL certTrusted (OS trust store check, not just file exists).
// #14070: resolve the file the active migration decision actually
// installs (ca.crt under the root-CA model) instead of always
// hard-coding the legacy server.crt path.
const certDir = path.join(resolveMitmDataDir(), "mitm");
const certPath = path.join(certDir, "server.crt");
const rootCaEnabled = process.env.MITM_ROOT_CA_ENABLED === "true";
const { certPath } = resolveActiveCertPath(certDir, rootCaEnabled);
const certExists = fs.existsSync(certPath);
const certTrusted = certExists ? await checkCertInstalled(certPath) : false;

View File

@@ -0,0 +1,34 @@
import path from "path";
import { decideCertMigration, type CertMigrationDecision } from "./migration.ts";
// #14070: single source of truth for "which cert file is actually active
// (installed + trusted) for this run". `decideCertMigration()` only answers
// which MODEL is active; every caller that needs the concrete file path used
// to independently re-derive it, and three of the four call sites (the
// AgentBridge `state`/`diagnose`/`trust-cert` routes) hard-coded the legacy
// `server.crt` path regardless of the decision — so once a install adopted
// the root-CA model (`ca.crt`), those routes kept checking/trusting the
// wrong file forever. `startMitmInternal()` (`../manager.ts`) is the only
// place that got this right; this helper factors that logic out so every
// caller (including `manager.ts` itself) resolves the same path the same
// way.
/** The cert file actually active for this run, plus which model produced it. */
export interface ActiveCertInfo {
certPath: string;
mode: CertMigrationDecision;
}
/**
* Resolve the cert file the active migration decision installs/trusts for
* `certDir`. Mirrors `rootCa.ts`'s `ca.crt` filename under `"use-root-ca"`
* and `generate.ts`'s `server.crt` filename under `"use-legacy-leaf"` — pure
* path arithmetic, no filesystem I/O beyond what `decideCertMigration()`
* itself performs (existence checks only).
*/
export function resolveActiveCertPath(certDir: string, rootCaEnabled: boolean): ActiveCertInfo {
const mode = decideCertMigration(certDir, rootCaEnabled);
const certPath =
mode === "use-root-ca" ? path.join(certDir, "ca.crt") : path.join(certDir, "server.crt");
return { certPath, mode };
}

View File

@@ -12,7 +12,7 @@ import { provisionDnsEntries } from "./dns/provision.ts";
import { generateCert } from "./cert/generate.ts";
import { installCertResult, installCaCert } from "./cert/install.ts";
import { loadOrCreateMitmCa, resolveMitmCertDir } from "./cert/rootCa.ts";
import { decideCertMigration } from "./cert/migration.ts";
import { resolveActiveCertPath } from "./cert/activeCert.ts";
import { ALL_TARGETS } from "./targets/index.ts";
import { detectAgent } from "./detection/index.ts";
import type { AgentId, DetectionResult, MitmTarget } from "./types.ts";
@@ -419,9 +419,13 @@ export async function getMitmStatus(agentId?: string): Promise<{
// Ignore
}
// Check cert
// Check cert. #14070: resolve the file the active migration decision
// actually installs (ca.crt under the root-CA model), not always the
// legacy server.crt — otherwise a root-CA install with no leaf ever
// generated would wrongly report certExists:false.
const certDir = path.join(resolveMitmDataDir(), "mitm");
const certExists = fs.existsSync(path.join(certDir, "server.crt"));
const rootCaEnabledForStatus = process.env.MITM_ROOT_CA_ENABLED === "true";
const certExists = fs.existsSync(resolveActiveCertPath(certDir, rootCaEnabledForStatus).certPath);
return {
running,
@@ -516,7 +520,7 @@ async function startMitmInternal(
// `tproxy/dynamicCert.ts`).
const certDir = resolveMitmCertDir();
const rootCaEnabled = process.env.MITM_ROOT_CA_ENABLED === "true";
const migrationDecision = decideCertMigration(certDir, rootCaEnabled);
const { mode: migrationDecision } = resolveActiveCertPath(certDir, rootCaEnabled);
let certPath: string;
if (migrationDecision === "use-legacy-leaf") {
certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");

View File

@@ -0,0 +1,129 @@
// Regression test for issue #14070 — "[Windows] AgentBridge reports
// certificate untrusted and DNS off despite manual configuration".
//
// Root cause: the AgentBridge dashboard's cert-trust check
// (/api/tools/agent-bridge/state, /diagnose, and the "trust-cert" manual
// action) always read the LEGACY leaf server.crt from disk and computed its
// OS-trust-store fingerprint from that file — regardless of which cert model
// (cert/migration.ts::decideCertMigration) is actually active for this run.
// startMitmInternal() (src/mitm/manager.ts) picks the correct model per-run
// and, under "use-root-ca", installs/trusts ca.crt instead — a DIFFERENT
// keypair with a DIFFERENT fingerprint.
//
// Fix: src/mitm/cert/activeCert.ts::resolveActiveCertPath() is the single
// source of truth for "which cert file is active for this run", reused by
// manager.ts and by all three AgentBridge route handlers.
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { decideCertMigration } from "../../src/mitm/cert/migration.ts";
import { loadOrCreateMitmCa } from "../../src/mitm/cert/rootCa.ts";
import { certutilThumbprint } from "../../src/mitm/cert/install.ts";
import { generateCert } from "../../src/mitm/cert/generate.ts";
import { resolveActiveCertPath } from "../../src/mitm/cert/activeCert.ts";
function tmpDataDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-agentbridge-"));
}
test("AgentBridge cert-trust check now targets the cert file the active migration decision actually installs (root-CA model)", async () => {
const dataDir = tmpDataDir();
const previousDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = dataDir;
try {
const certDir = path.join(dataDir, "mitm");
fs.mkdirSync(certDir, { recursive: true });
// Recreate the reporter's on-disk state: a legacy leaf from an earlier
// run, PLUS a persisted CA pair — the combination that activates the
// root-CA model this run (decideCertMigration()).
await generateCert();
const legacyLeafPath = path.join(certDir, "server.crt");
assert.equal(fs.existsSync(legacyLeafPath), true, "server.crt must exist for this scenario");
const ca = await loadOrCreateMitmCa(certDir);
assert.equal(fs.existsSync(ca.certPath), true, "ca.crt must exist for this scenario");
const migrationDecision = decideCertMigration(certDir, false);
assert.equal(
migrationDecision,
"use-root-ca",
"with both a legacy leaf and a CA pair on disk, manager.ts activates the root-CA model this run"
);
const actuallyInstalledCertPath = ca.certPath;
// This is what the dashboard's cert-trust check now resolves to.
const { certPath: dashboardCheckedCertPath, mode } = resolveActiveCertPath(certDir, false);
assert.equal(mode, "use-root-ca");
assert.equal(
dashboardCheckedCertPath,
actuallyInstalledCertPath,
"FIX: the dashboard's cert-trust check must target the same file (ca.crt) manager.ts actually installed/trusted for this run's active cert model"
);
const dashboardCheckedThumbprint = certutilThumbprint(dashboardCheckedCertPath);
const actuallyTrustedThumbprint = certutilThumbprint(actuallyInstalledCertPath);
assert.equal(
dashboardCheckedThumbprint,
actuallyTrustedThumbprint,
"FIX: the fingerprint the dashboard looks up in the OS store must be the fingerprint of the cert that was actually installed there"
);
} finally {
if (previousDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = previousDataDir;
fs.rmSync(dataDir, { recursive: true, force: true });
}
});
test("AgentBridge cert-trust check still targets server.crt for a legacy-only install (no CA pair, no behavior change)", async () => {
const dataDir = tmpDataDir();
const previousDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = dataDir;
try {
const certDir = path.join(dataDir, "mitm");
fs.mkdirSync(certDir, { recursive: true });
await generateCert();
const legacyLeafPath = path.join(certDir, "server.crt");
assert.equal(fs.existsSync(legacyLeafPath), true, "server.crt must exist for this scenario");
assert.equal(
fs.existsSync(path.join(certDir, "ca.crt")),
false,
"no CA pair must exist for this scenario"
);
const { certPath, mode } = resolveActiveCertPath(certDir, false);
assert.equal(mode, "use-legacy-leaf");
assert.equal(
certPath,
legacyLeafPath,
"a pre-existing legacy-only install must keep resolving to server.crt — no silent upgrade"
);
} finally {
if (previousDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = previousDataDir;
fs.rmSync(dataDir, { recursive: true, force: true });
}
});
test("resolveActiveCertPath: fresh install with the flag on resolves to ca.crt (matches decideCertMigration)", () => {
const dataDir = tmpDataDir();
try {
const certDir = path.join(dataDir, "mitm");
fs.mkdirSync(certDir, { recursive: true });
const { certPath, mode } = resolveActiveCertPath(certDir, true);
assert.equal(mode, "use-root-ca");
assert.equal(certPath, path.join(certDir, "ca.crt"));
} finally {
fs.rmSync(dataDir, { recursive: true, force: true });
}
});