fix(tls): bound self-signed certificate generation (#109139)

* fix(tls): bound self-signed certificate generation

* fix(tls): stage generated certificate material

* docs(changelog): note gateway TLS generation hardening

Co-authored-by: Alix-007 <li.long15@xydigit.com>

* fix(tls): make generated pair publication recoverable

Co-authored-by: Alix-007 <li.long15@xydigit.com>

* test(tls): narrow recovery directory fixture

* fix(tls): harden atomic certificate publication

* chore: drop PR changelog edit

Co-authored-by: Alix-007 <li.long15@xydigit.com>

* fix(tls): degrade atomic publication failures

Keep self-signed certificate generation bounded and staged, but fall back to exclusive best-effort writes when hard-link publication is unavailable. Emit a typed gateway TLS degradation warning and preserve startup availability on supported non-atomic filesystems.\n\nCo-authored-by: Alix-007 <li.long15@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Alix-007
2026-07-21 23:57:21 +08:00
committed by GitHub
parent 3a5b2764f8
commit cdf69d9b8f
2 changed files with 250 additions and 35 deletions
+132 -7
View File
@@ -1,16 +1,44 @@
// Covers gateway TLS loading, fingerprint reporting, generated certificate
// paths, and error handling for missing or invalid material.
import { X509Certificate } from "node:crypto";
import { writeFile } from "node:fs/promises";
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js";
import { normalizeFingerprint } from "./fingerprint.js";
const { resolveSystemBinMock, runExecMock } = vi.hoisted(() => ({
resolveSystemBinMock: vi.fn(() => "/usr/bin/openssl"),
runExecMock: vi.fn(),
}));
vi.mock("../../process/exec.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../process/exec.js")>()),
runExec: runExecMock,
}));
vi.mock("../resolve-system-bin.js", () => ({ resolveSystemBin: resolveSystemBinMock }));
import { loadGatewayTlsRuntime } from "./gateway.js";
const tempDirs = createTrackedTempDirs();
const createTempDir = () => tempDirs.make("openclaw-gateway-tls-test-");
function resolveOpenSslOutput(args: string[], flag: "-keyout" | "-out"): string {
const outputPath = args.at(args.indexOf(flag) + 1);
if (!outputPath) {
throw new Error(`missing ${flag} output path`);
}
return outputPath;
}
async function writeGeneratedTlsPair(args: string[]): Promise<void> {
await Promise.all([
fs.writeFile(resolveOpenSslOutput(args, "-out"), CERT_PEM, "utf8"),
fs.writeFile(resolveOpenSslOutput(args, "-keyout"), KEY_PEM, "utf8"),
]);
}
const KEY_PEM = [
"-----BEGIN PRIVATE KEY-----", // pragma: allowlist secret
"MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDrur5CWp4psMMb",
@@ -63,6 +91,8 @@ r1USnb+wUdA7Zoj/mQ==
-----END CERTIFICATE-----`;
afterEach(async () => {
resolveSystemBinMock.mockClear();
runExecMock.mockReset();
await tempDirs.cleanup();
});
@@ -83,9 +113,9 @@ describe("loadGatewayTlsRuntime", () => {
const certPath = path.join(dir, "gateway-cert.pem");
const keyPath = path.join(dir, "gateway-key.pem");
const caPath = path.join(dir, "gateway-ca.pem");
await writeFile(certPath, CERT_PEM, "utf8");
await writeFile(keyPath, KEY_PEM, "utf8");
await writeFile(caPath, CERT_PEM, "utf8");
await fs.writeFile(certPath, CERT_PEM, "utf8");
await fs.writeFile(keyPath, KEY_PEM, "utf8");
await fs.writeFile(caPath, CERT_PEM, "utf8");
const result = await loadGatewayTlsRuntime({
enabled: true,
@@ -129,12 +159,107 @@ describe("loadGatewayTlsRuntime", () => {
expect(result.error).toBe("gateway tls: cert/key missing");
});
it.each(["key", "cert"] as const)(
"bounds generation and cleans a partial staged %s",
async (partialOutput) => {
const dir = await createTempDir();
const certPath = path.join(dir, "gateway-cert.pem");
const keyPath = path.join(dir, "gateway-key.pem");
runExecMock.mockImplementationOnce(async (_command: string, args: string[]) => {
const outputPath = resolveOpenSslOutput(args, partialOutput === "key" ? "-keyout" : "-out");
await fs.writeFile(outputPath, partialOutput === "key" ? KEY_PEM : CERT_PEM, "utf8");
throw new Error("openssl timed out");
});
const result = await loadGatewayTlsRuntime({ enabled: true, certPath, keyPath });
expect(runExecMock).toHaveBeenCalledOnce();
const [command, args, options] = runExecMock.mock.calls[0] as [
string,
string[],
{ logOutput: boolean; timeoutMs: number },
];
expect(command).toBe("/usr/bin/openssl");
expect(resolveOpenSslOutput(args, "-keyout")).not.toBe(keyPath);
expect(resolveOpenSslOutput(args, "-out")).not.toBe(certPath);
expect(options).toEqual({ logOutput: false, timeoutMs: 30_000 });
expect(result).toMatchObject({ enabled: false, required: true, certPath, keyPath });
expect(result.error).toContain("openssl timed out");
await expect(fs.access(certPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.access(keyPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.readdir(dir)).resolves.toEqual([]);
},
);
it("validates and publishes a generated pair with private modes", async () => {
const dir = await createTempDir();
const certPath = path.join(dir, "gateway-cert.pem");
const keyPath = path.join(dir, "gateway-key.pem");
runExecMock.mockImplementation(async (_command: string, args: string[]) => {
await writeGeneratedTlsPair(args);
});
const result = await loadGatewayTlsRuntime({ enabled: true, certPath, keyPath });
expect(result.enabled).toBe(true);
await expect(fs.readFile(certPath, "utf8")).resolves.toBe(CERT_PEM);
await expect(fs.readFile(keyPath, "utf8")).resolves.toBe(KEY_PEM);
await expect(fs.readdir(dir).then((entries) => entries.toSorted())).resolves.toEqual([
"gateway-cert.pem",
"gateway-key.pem",
]);
if (process.platform !== "win32") {
expect((await fs.stat(certPath)).mode & 0o777).toBe(0o600);
expect((await fs.stat(keyPath)).mode & 0o777).toBe(0o600);
}
});
it("publishes best-effort and warns once when hard links are unavailable", async () => {
const dir = await createTempDir();
const certPath = path.join(dir, "gateway-cert.pem");
const keyPath = path.join(dir, "gateway-key.pem");
const warn = vi.fn();
runExecMock.mockImplementation(async (_command: string, args: string[]) => {
await writeGeneratedTlsPair(args);
});
const linkSpy = vi
.spyOn(fs, "link")
.mockRejectedValue(Object.assign(new Error("hard links unsupported"), { code: "ENOTSUP" }));
let result: Awaited<ReturnType<typeof loadGatewayTlsRuntime>> | undefined;
try {
result = await loadGatewayTlsRuntime({ enabled: true, certPath, keyPath }, { warn });
} finally {
linkSpy.mockRestore();
}
expect(result?.enabled).toBe(true);
expect(warn).toHaveBeenCalledOnce();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("[GATEWAY_TLS_DEGRADED]"), {
event: "gateway.tls.degraded",
ownerKind: "gateway",
ownerId: "tls",
reason: "atomic hard-link publication unavailable",
state: "best-effort",
});
await expect(fs.readFile(certPath, "utf8")).resolves.toBe(CERT_PEM);
await expect(fs.readFile(keyPath, "utf8")).resolves.toBe(KEY_PEM);
await expect(fs.readdir(dir).then((entries) => entries.toSorted())).resolves.toEqual([
"gateway-cert.pem",
"gateway-key.pem",
]);
if (process.platform !== "win32") {
expect((await fs.stat(certPath)).mode & 0o777).toBe(0o600);
expect((await fs.stat(keyPath)).mode & 0o777).toBe(0o600);
}
});
it("reports load failures for invalid pem files", async () => {
const dir = await createTempDir();
const certPath = path.join(dir, "gateway-cert.pem");
const keyPath = path.join(dir, "gateway-key.pem");
await writeFile(certPath, "not a certificate\n", "utf8");
await writeFile(keyPath, KEY_PEM, "utf8");
await fs.writeFile(certPath, "not a certificate\n", "utf8");
await fs.writeFile(keyPath, KEY_PEM, "utf8");
const result = await loadGatewayTlsRuntime({
enabled: true,
+118 -28
View File
@@ -11,6 +11,61 @@ import { pathExists } from "../fs-safe.js";
import { resolveSystemBin } from "../resolve-system-bin.js";
import { normalizeFingerprint } from "./fingerprint.js";
const GATEWAY_TLS_CERT_GENERATION_TIMEOUT_MS = 30_000;
type GatewayTlsLog = {
info?: (message: string) => void;
warn?: (message: string, meta?: Record<string, unknown>) => void;
};
type GatewayTlsDegradation = {
event: "gateway.tls.degraded";
ownerKind: "gateway";
ownerId: "tls";
reason: "atomic hard-link publication unavailable";
state: "best-effort";
};
const GATEWAY_TLS_DEGRADATION: GatewayTlsDegradation = {
event: "gateway.tls.degraded",
ownerKind: "gateway",
ownerId: "tls",
reason: "atomic hard-link publication unavailable",
state: "best-effort",
};
function isHardLinkUnsupportedError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EPERM";
}
async function publishGeneratedTlsOutput(
stagedPath: string,
finalPath: string,
contents: string,
): Promise<boolean> {
try {
await fs.link(stagedPath, finalPath);
return false;
} catch (error) {
if (!isHardLinkUnsupportedError(error)) {
throw error;
}
}
// Some supported filesystems cannot publish with hard links. An exclusive handle keeps
// no-overwrite semantics without pathname cleanup that could delete concurrent output;
// a failed best-effort write may leave this attempt's partial file for operator cleanup.
const handle = await fs.open(finalPath, "wx", 0o600);
try {
await handle.writeFile(contents, "utf8");
await handle.sync();
} finally {
await handle.close();
}
return true;
}
// Gateway TLS runtime carries loaded cert material plus the normalized SHA-256
// fingerprint advertised to clients.
export type GatewayTlsRuntime = {
@@ -27,7 +82,7 @@ export type GatewayTlsRuntime = {
async function generateSelfSignedCert(params: {
certPath: string;
keyPath: string;
log?: { info?: (msg: string) => void };
log?: GatewayTlsLog;
}): Promise<void> {
const certDir = path.dirname(params.certPath);
const keyDir = path.dirname(params.keyPath);
@@ -41,39 +96,74 @@ async function generateSelfSignedCert(params: {
"openssl not found in trusted system directories. Install it in an OS-managed location.",
);
}
// Use argv execution with a trusted system binary; certificate paths are arguments,
// not shell text.
await runExec(
opensslBin,
[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-sha256",
"-days",
"3650",
"-nodes",
"-keyout",
params.keyPath,
"-out",
const certStageDir = await fs.mkdtemp(path.join(certDir, ".openclaw-gateway-tls-cert-"));
const stagedCertPath = path.join(certStageDir, "cert.pem");
let keyStageDir: string | undefined;
try {
keyStageDir = await fs.mkdtemp(path.join(keyDir, ".openclaw-gateway-tls-key-"));
const stagedKeyPath = path.join(keyStageDir, "key.pem");
await Promise.all([fs.chmod(certStageDir, 0o700), fs.chmod(keyStageDir, 0o700)]);
// OpenSSL never sees the configured final paths, so timeout and generation
// failures cannot strand a half-written certificate pair there.
await runExec(
opensslBin,
[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-sha256",
"-days",
"3650",
"-nodes",
"-keyout",
stagedKeyPath,
"-out",
stagedCertPath,
"-subj",
"/CN=openclaw-gateway",
],
{
logOutput: false,
timeoutMs: GATEWAY_TLS_CERT_GENERATION_TIMEOUT_MS,
},
);
await Promise.all([fs.chmod(stagedKeyPath, 0o600), fs.chmod(stagedCertPath, 0o600)]);
const [cert, key] = await Promise.all([
fs.readFile(stagedCertPath, "utf8"),
fs.readFile(stagedKeyPath, "utf8"),
]);
tls.createSecureContext({ cert, key, minVersion: "TLSv1.3" });
let usedBestEffortPublication = await publishGeneratedTlsOutput(
stagedCertPath,
params.certPath,
"-subj",
"/CN=openclaw-gateway",
],
{ logOutput: false },
);
await fs.chmod(params.keyPath, 0o600).catch(() => {});
await fs.chmod(params.certPath, 0o600).catch(() => {});
params.log?.info?.(
`gateway tls: generated self-signed cert at ${shortenHomeInString(params.certPath)}`,
);
cert,
);
usedBestEffortPublication =
(await publishGeneratedTlsOutput(stagedKeyPath, params.keyPath, key)) ||
usedBestEffortPublication;
if (usedBestEffortPublication) {
params.log?.warn?.(
`[GATEWAY_TLS_DEGRADED] best-effort gateway:tls: ${GATEWAY_TLS_DEGRADATION.reason}.`,
GATEWAY_TLS_DEGRADATION,
);
}
params.log?.info?.(
`gateway tls: generated self-signed cert at ${shortenHomeInString(params.certPath)}`,
);
} finally {
await Promise.allSettled(
[certStageDir, keyStageDir]
.filter((dir): dir is string => Boolean(dir))
.map((dir) => fs.rm(dir, { force: true, recursive: true })),
);
}
}
/** Load or generate gateway TLS material and return server-ready TLS options. */
export async function loadGatewayTlsRuntime(
cfg: GatewayTlsConfig | undefined,
log?: { info?: (msg: string) => void; warn?: (msg: string) => void },
log?: GatewayTlsLog,
): Promise<GatewayTlsRuntime> {
if (!cfg || cfg.enabled !== true) {
return { enabled: false, required: false };