From 57524daa72916350775d714ef25ae5db696c694b Mon Sep 17 00:00:00 2001 From: Jim Pudar Date: Wed, 3 Jun 2026 06:11:22 -0400 Subject: [PATCH] Fix CA generation for Azure CLI trust --- src/rootcell/rootcell.test.ts | 183 +++++++++++++++++++++++++++++++++- src/rootcell/rootcell.ts | 115 +++++++++++++++++---- 2 files changed, 273 insertions(+), 25 deletions(-) diff --git a/src/rootcell/rootcell.test.ts b/src/rootcell/rootcell.test.ts index 059ad4c..0d292fb 100644 --- a/src/rootcell/rootcell.test.ts +++ b/src/rootcell/rootcell.test.ts @@ -1135,6 +1135,100 @@ describe("VM and network providers", () => { ]); }); + test("app lifecycle repairs legacy MITM CA certificates and reprovisions VMs", async () => { + const repo = makeInstanceRepo(); + const buildSpyArtifacts = vi.spyOn( + RootcellApp.prototype as unknown as { buildSpyArtifacts: () => void }, + "buildSpyArtifacts", + ).mockImplementation(() => undefined); + try { + const env = instanceEnv(repo); + const config = buildConfig(repo, env, fakeInstance("dev", repo, env)); + mkdirSync(config.generatedDir, { recursive: true }); + mkdirSync(config.proxyDir, { recursive: true }); + for (const file of ["allowed-https.txt", "allowed-ssh.txt", "allowed-dns.txt"]) { + writeFileSync(join(config.proxyDir, file), "\n", "utf8"); + } + writeLegacyAgentCa(config.pkiDir); + const crt = join(config.pkiDir, "agent-vm-ca-cert.pem"); + expect(certText(crt)).not.toContain("X509v3 Subject Key Identifier"); + + const calls: string[] = []; + const copiedGuestPaths: string[] = []; + const attachment: VmNetworkAttachment = { kind: "fake" }; + const providers: ProviderBundle = { + network: { + id: "fake-network", + plan: () => ({ + provider: "fake-network", + guest: { + firewallIp: config.firewallIp, + agentIp: config.agentIp, + networkPrefix: 24, + agentPrivateInterface: "agent0", + firewallPrivateInterface: "firewall0", + firewallEgressInterface: "egress0", + }, + vms: { + agent: attachment, + firewall: attachment, + }, + }), + preflight: () => Promise.resolve(), + stop: () => Promise.resolve(), + remove: () => Promise.resolve(), + ensureReady: () => Promise.resolve(), + }, + vm: { + id: "fake-vm", + status: () => Promise.resolve({ state: "running" }), + stopIfRunning: () => Promise.resolve(), + forceStopIfRunning: () => Promise.resolve(), + remove: () => Promise.resolve(), + assertCompatible: () => Promise.resolve(), + ensureRunning: (input) => { + calls.push(`ensure:${input.role}:${input.name}`); + return Promise.resolve({ created: false }); + }, + exec: (name, args) => { + calls.push(`exec:${name}:${args.join(" ")}`); + return Promise.resolve({ status: 0 }); + }, + execCapture: () => Promise.resolve({ status: 0, stdout: "", stderr: "" }), + execInteractive: (name) => { + calls.push(`interactive:${name}`); + return Promise.resolve(0); + }, + copyToGuest: (_name, _hostPath, guestPath) => { + copiedGuestPaths.push(guestPath); + return Promise.resolve(); + }, + forwardLocalPort: (_name, options) => Promise.resolve({ + ...options, + closed: Promise.resolve(0), + close: () => Promise.resolve(), + }), + }, + secrets: new StaticSecretProviderRegistry([]), + }; + + const status = await new RootcellApp(config, providers).runAfterEnvironment("", [], { open: true }); + + expect(status).toBe(0); + const updatedCert = certText(crt); + expect(updatedCert).toContain("X509v3 Subject Key Identifier"); + expect(updatedCert).toContain("X509v3 Authority Key Identifier"); + expect(calls).toContain(`ensure:firewall:${config.firewallVm}`); + expect(calls).toContain(`ensure:agent:${config.agentVm}`); + expect(copiedGuestPaths).toContain("/tmp/.agent-vm-ca.pem.staged"); + expect(copiedGuestPaths).toContain(`${config.guestRepoDir}/pki/agent-vm-ca-cert.pem`); + expect(calls).toContain(`interactive:${config.agentVm}`); + } finally { + buildSpyArtifacts.mockRestore(); + rmSync(repo, { recursive: true, force: true }); + } + }); + test("spy uses the shared tunnel fallback and foreground close path", async () => { const repo = makeInstanceRepo(); const oldSpyEnabled = process.env.ROOTCELL_SPY_ENABLED; @@ -1146,10 +1240,7 @@ describe("VM and network providers", () => { mkdirSync(config.instanceDir, { recursive: true }); mkdirSync(config.generatedDir, { recursive: true }); mkdirSync(config.proxyDir, { recursive: true }); - mkdirSync(config.pkiDir, { recursive: true, mode: 0o700 }); - for (const file of ["agent-vm-ca.key", "agent-vm-ca-cert.pem", "agent-vm-ca.pem"]) { - writeFileSync(join(config.pkiDir, file), "test\n", "utf8"); - } + writeStrictAgentCa(config.pkiDir); for (const file of ["allowed-https.txt", "allowed-ssh.txt", "allowed-dns.txt"]) { writeFileSync(join(config.proxyDir, file), "\n", "utf8"); } @@ -3002,6 +3093,90 @@ function makeInstanceRepo(): string { return repo; } +function writeLegacyAgentCa(pkiDir: string): void { + writeAgentCa(pkiDir, false); +} + +function writeStrictAgentCa(pkiDir: string): void { + writeAgentCa(pkiDir, true); +} + +function writeAgentCa(pkiDir: string, strict: boolean): void { + mkdirSync(pkiDir, { recursive: true, mode: 0o700 }); + const key = join(pkiDir, "agent-vm-ca.key"); + const crt = join(pkiDir, "agent-vm-ca-cert.pem"); + const pem = join(pkiDir, "agent-vm-ca.pem"); + if (!strict) { + const csr = join(pkiDir, "agent-vm-ca.csr"); + runCapture("openssl", [ + "req", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + key, + "-out", + csr, + "-subj", + "/CN=agent-vm proxy CA", + ]); + runCapture("openssl", [ + "x509", + "-req", + "-in", + csr, + "-signkey", + key, + "-out", + crt, + "-days", + "3650", + ]); + rmSync(csr, { force: true }); + writeFileSync(pem, readFileSync(key, "utf8") + readFileSync(crt, "utf8"), "utf8"); + return; + } + + const config = join(pkiDir, "openssl-test.cnf"); + writeFileSync(config, [ + "[req]", + "prompt = no", + "distinguished_name = dn", + "x509_extensions = v3_ca", + "[dn]", + "CN = agent-vm proxy CA", + "[v3_ca]", + "basicConstraints = critical,CA:TRUE,pathlen:0", + "keyUsage = critical,keyCertSign,cRLSign", + "subjectKeyIdentifier = hash", + "authorityKeyIdentifier = keyid:always", + "", + ].join("\n"), "utf8"); + runCapture("openssl", [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + key, + "-out", + crt, + "-days", + "3650", + "-config", + config, + "-extensions", + "v3_ca", + ]); + rmSync(config, { force: true }); + writeFileSync(pem, readFileSync(key, "utf8") + readFileSync(crt, "utf8"), "utf8"); +} + +function certText(path: string): string { + return runCapture("openssl", ["x509", "-in", path, "-noout", "-text"]).stdout; +} + function restoreEnv(name: string, value: string | undefined): void { if (value === undefined) { Reflect.deleteProperty(process.env, name); diff --git a/src/rootcell/rootcell.ts b/src/rootcell/rootcell.ts index 83d6548..bbb5695 100644 --- a/src/rootcell/rootcell.ts +++ b/src/rootcell/rootcell.ts @@ -3,6 +3,8 @@ import { existsSync, mkdirSync, readFileSync, + renameSync, + rmSync, writeFileSync, } from "node:fs"; import { dirname, join, resolve } from "node:path"; @@ -161,6 +163,14 @@ function shellQuote(value: string): string { const AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"; const AGENT_CA_DIR = "/etc/ssl/certs"; +const AGENT_CA_DAYS = "3650"; +const AGENT_CA_SUBJECT = "/CN=agent-vm proxy CA"; +const AGENT_CA_EXTENSIONS = [ + "basicConstraints=critical,CA:TRUE,pathlen:0", + "keyUsage=critical,keyCertSign,cRLSign", + "subjectKeyIdentifier=hash", + "authorityKeyIdentifier=keyid:always", +] as const; const AGENT_CA_ENV = [ ["NODE_EXTRA_CA_CERTS", AGENT_CA_BUNDLE], ["NIX_SSL_CERT_FILE", AGENT_CA_BUNDLE], @@ -186,6 +196,19 @@ function sudoAgentCaEnvScript(): string { return AGENT_CA_ENV.map(([key]) => ` ${key}="$${key}" \\`).join("\n"); } +function agentCaExtensionArgs(): readonly string[] { + return AGENT_CA_EXTENSIONS.flatMap((extension) => ["-addext", extension]); +} + +function agentCaHasStrictExtensions(crt: string): boolean { + const result = runCapture("openssl", ["x509", "-in", crt, "-noout", "-text"], { + allowFailure: true, + }); + return result.status === 0 + && result.stdout.includes("X509v3 Subject Key Identifier") + && result.stdout.includes("X509v3 Authority Key Identifier"); +} + function nixStringList(values: readonly string[]): string { return `[ ${values.map(nixString).join(" ")} ]`; } @@ -295,10 +318,11 @@ export class RootcellApp { await this.providers.vm.forceStopIfRunning(name); }, }); - if (!await this.ensureFirewall(subcommand === "provision", { allowProvision: subcommand !== "spy" })) { + const caChanged = this.ensureCa(); + const needsProvisionForCa = subcommand === "provision" || caChanged; + if (!await this.ensureFirewall(needsProvisionForCa, { allowProvision: subcommand !== "spy" })) { return 1; } - this.ensureCa(); await this.syncAllowlists(); await this.waitForFirewallListeners(); @@ -306,7 +330,7 @@ export class RootcellApp { return await this.runSpy(spyOptions); } - await this.ensureAgent(subcommand === "provision"); + await this.ensureAgent(needsProvisionForCa); if (subcommand === "provision") { log("done."); return 0; @@ -616,18 +640,44 @@ exit 1 throw new Error(`timeout waiting for SSH transport to ${this.config.firewallVm}${lastError.length === 0 ? "" : `: ${lastError}`}`); } - private ensureCa(): void { + private ensureCa(): boolean { const dir = this.config.pkiDir; const key = join(dir, "agent-vm-ca.key"); const crt = join(dir, "agent-vm-ca-cert.pem"); const pem = join(dir, "agent-vm-ca.pem"); - if (existsSync(key) && existsSync(crt) && existsSync(pem)) { - return; - } - - log(`generating TLS-MITM CA for instance '${this.config.instanceName}' (one-time, persists across runs)`); mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700); + + let changed = false; + if (!existsSync(key) || !existsSync(crt)) { + log(`generating TLS-MITM CA for instance '${this.config.instanceName}' (one-time, persists across runs)`); + this.generateCa(key, crt); + changed = true; + } else if (!agentCaHasStrictExtensions(crt)) { + log("updating TLS-MITM CA certificate for strict Python/OpenSSL certificate verification"); + if (!this.reissueCaCert(key, crt)) { + log("existing TLS-MITM CA key/cert could not be reused; generating a fresh CA"); + this.generateCa(key, crt); + } + changed = true; + } + + const pemContent = readFileSync(key, "utf8") + readFileSync(crt, "utf8"); + if (!existsSync(pem) || readFileSync(pem, "utf8") !== pemContent) { + writeFileSync(pem, pemContent, "utf8"); + changed = true; + } + chmodSync(key, 0o600); + chmodSync(pem, 0o600); + chmodSync(crt, 0o644); + return changed; + } + + private generateCa(key: string, crt: string): void { + const tmpKey = `${key}.tmp`; + const tmpCrt = `${crt}.tmp`; + rmSync(tmpKey, { force: true }); + rmSync(tmpCrt, { force: true }); runInherited("openssl", [ "req", "-x509", @@ -635,22 +685,45 @@ exit 1 "rsa:2048", "-nodes", "-keyout", - key, + tmpKey, "-out", - crt, + tmpCrt, "-days", - "3650", + AGENT_CA_DAYS, "-subj", - "/CN=agent-vm proxy CA", - "-addext", - "basicConstraints=critical,CA:TRUE,pathlen:0", - "-addext", - "keyUsage=critical,keyCertSign,cRLSign", + AGENT_CA_SUBJECT, + ...agentCaExtensionArgs(), ], { ignoredOutput: true }); - writeFileSync(pem, readFileSync(key, "utf8") + readFileSync(crt, "utf8"), "utf8"); - chmodSync(key, 0o600); - chmodSync(pem, 0o600); - chmodSync(crt, 0o644); + renameSync(tmpKey, key); + renameSync(tmpCrt, crt); + } + + private reissueCaCert(key: string, crt: string): boolean { + const tmpCrt = `${crt}.tmp`; + rmSync(tmpCrt, { force: true }); + const result = runInherited("openssl", [ + "req", + "-x509", + "-new", + "-key", + key, + "-out", + tmpCrt, + "-days", + AGENT_CA_DAYS, + "-subj", + AGENT_CA_SUBJECT, + ...agentCaExtensionArgs(), + ], { + allowFailure: true, + ignoredOutput: true, + }); + if (result.status !== 0) { + rmSync(tmpCrt, { force: true }); + return false; + } + renameSync(tmpCrt, crt); + return true; } private async syncFirewallCa(): Promise {