Compare commits

...

5 Commits

Author SHA1 Message Date
Vincent Koc
9761a71a5a
Merge branch 'main' into vincentkoc-code/security-hardening-auth-timeouts 2026-03-07 12:48:36 -05:00
Vincent Koc
bf202cd6e3 Changelog: note gateway auth hardening 2026-03-07 09:42:43 -08:00
Vincent Koc
da5770df67 Sessions: reassert mirrored transcript permissions 2026-03-07 09:42:36 -08:00
Vincent Koc
bbc3bd9cf2 Auth: support sealed auth store files 2026-03-07 09:42:25 -08:00
Vincent Koc
7f9eaed281 Gateway: add request timeouts to client RPCs 2026-03-07 09:42:15 -08:00
9 changed files with 299 additions and 14 deletions

View File

@ -747,6 +747,7 @@ Docs: https://docs.openclaw.ai
- Onboarding/Custom providers: use Azure OpenAI-specific verification auth/payload shape (`api-key`, deployment-path chat completions payload) when probing Azure endpoints so valid Azure custom-provider setup no longer fails preflight. (#29421) Thanks @kunalk16.
- Feishu/Docx editing tools: add `feishu_doc` positional insert, table row/column operations, table-cell merge, and color-text updates; switch markdown write/append/insert to Descendant API insertion with large-document batching; and harden image uploads for data URI/base64/local-path inputs with strict validation and routing-safe upload metadata. (#29411) Thanks @Elarwei001.
- Commands/Owner-only tools: treat identified direct-chat senders as owners when no owner allowlist is configured, while preserving internal `operator.admin` owner sessions. (#26331) thanks @widingmarcus-cyber
- Gateway/Auth hardening: add bounded `GatewayClient` request timeouts, support optional `OPENCLAW_PASSPHRASE` sealing for OpenClaw-owned auth stores (`auth-profiles.json` and legacy `oauth.json`), and re-assert `0600` permissions after mirrored transcript writes. Thanks @alamine42 and @vincentkoc.
## 2026.2.26

View File

@ -2,6 +2,9 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { isSealedJsonText } from "../infra/sealed-json-file.js";
import { withEnvAsync } from "../test-utils/env.js";
import { ensureAuthProfileStore } from "./auth-profiles.js";
import { resolveAuthStorePath } from "./auth-profiles/paths.js";
import { saveAuthProfileStore } from "./auth-profiles/store.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
@ -61,4 +64,37 @@ describe("saveAuthProfileStore", () => {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("seals auth-profiles.json when OPENCLAW_PASSPHRASE is set", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-"));
try {
const store: AuthProfileStore = {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-secret",
},
},
};
await withEnvAsync({ OPENCLAW_PASSPHRASE: "test-passphrase" }, async () => {
saveAuthProfileStore(store, agentDir);
const raw = await fs.readFile(resolveAuthStorePath(agentDir), "utf8");
expect(isSealedJsonText(raw)).toBe(true);
expect(raw).not.toContain("sk-secret");
const loaded = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
expect(loaded.profiles["openai:default"]).toMatchObject({
type: "api_key",
provider: "openai",
key: "sk-secret",
});
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
});

View File

@ -1,6 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { saveJsonFile } from "../../infra/json-file.js";
import { saveSealedJsonFile } from "../../infra/sealed-json-file.js";
import { resolveUserPath } from "../../utils.js";
import { resolveOpenClawAgentDir } from "../agent-paths.js";
import { AUTH_PROFILE_FILENAME, AUTH_STORE_VERSION, LEGACY_AUTH_FILENAME } from "./constants.js";
@ -29,5 +29,5 @@ export function ensureAuthStoreFile(pathname: string) {
version: AUTH_STORE_VERSION,
profiles: {},
};
saveJsonFile(pathname, payload);
saveSealedJsonFile(pathname, payload);
}

View File

@ -2,7 +2,12 @@ import fs from "node:fs";
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { resolveOAuthPath } from "../../config/paths.js";
import { withFileLock } from "../../infra/file-lock.js";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import { loadJsonFile } from "../../infra/json-file.js";
import {
SealedJsonPassphraseRequiredError,
loadSealedJsonFile,
saveSealedJsonFile,
} from "../../infra/sealed-json-file.js";
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js";
import { syncExternalCliCredentials } from "./external-cli-sync.js";
import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
@ -24,6 +29,19 @@ function resolveRuntimeStoreKey(agentDir?: string): string {
return resolveAuthStorePath(agentDir);
}
function loadProtectedAuthJson(pathname: string, label: string): unknown {
try {
return loadSealedJsonFile(pathname);
} catch (err) {
if (err instanceof SealedJsonPassphraseRequiredError) {
log.warn(`${label} is encrypted but OPENCLAW_PASSPHRASE is not set`, { pathname });
return undefined;
}
log.warn(`failed to load ${label}`, { pathname, err });
return undefined;
}
}
function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore {
return structuredClone(store);
}
@ -278,7 +296,7 @@ function mergeAuthProfileStores(
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
const oauthPath = resolveOAuthPath();
const oauthRaw = loadJsonFile(oauthPath);
const oauthRaw = loadProtectedAuthJson(oauthPath, "oauth.json");
if (!oauthRaw || typeof oauthRaw !== "object") {
return false;
}
@ -339,7 +357,7 @@ function applyLegacyStore(store: AuthProfileStore, legacy: LegacyAuthStore): voi
}
function loadCoercedStore(authPath: string): AuthProfileStore | null {
const raw = loadJsonFile(authPath);
const raw = loadProtectedAuthJson(authPath, "auth-profiles.json");
return coerceAuthStore(raw);
}
@ -350,7 +368,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
// Sync from external CLI tools on every load.
const synced = syncExternalCliCredentials(asStore);
if (synced) {
saveJsonFile(authPath, asStore);
saveSealedJsonFile(authPath, asStore);
}
return asStore;
}
@ -383,7 +401,7 @@ function loadAuthProfileStoreForAgent(
// sync external CLI credentials in-memory, but never persist while readOnly.
const synced = syncExternalCliCredentials(asStore);
if (synced && !readOnly) {
saveJsonFile(authPath, asStore);
saveSealedJsonFile(authPath, asStore);
}
return asStore;
}
@ -395,7 +413,7 @@ function loadAuthProfileStoreForAgent(
const mainStore = coerceAuthStore(mainRaw);
if (mainStore && Object.keys(mainStore.profiles).length > 0) {
// Clone main store to subagent directory for auth inheritance
saveJsonFile(authPath, mainStore);
saveSealedJsonFile(authPath, mainStore);
log.info("inherited auth-profiles from main agent", { agentDir });
return mainStore;
}
@ -417,7 +435,7 @@ function loadAuthProfileStoreForAgent(
const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1";
const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth || syncedCli);
if (shouldWrite) {
saveJsonFile(authPath, store);
saveSealedJsonFile(authPath, store);
}
// PR #368: legacy auth.json could get re-migrated from other agent dirs,
@ -505,5 +523,5 @@ export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string)
lastGood: store.lastGood ?? undefined,
usageStats: store.usageStats ?? undefined,
} satisfies AuthProfileStore;
saveJsonFile(authPath, payload);
saveSealedJsonFile(authPath, payload);
}

View File

@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import type { Api, Model } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { saveSealedJsonFile } from "../infra/sealed-json-file.js";
import { withEnvAsync } from "../test-utils/env.js";
import { ensureAuthProfileStore } from "./auth-profiles.js";
import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js";
@ -114,6 +115,41 @@ describe("getApiKeyForModel", () => {
}
});
it("migrates sealed legacy oauth.json into auth-profiles.json", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-"));
try {
const agentDir = path.join(tempDir, "agent");
await withEnvAsync(
{
OPENCLAW_PASSPHRASE: "test-passphrase",
OPENCLAW_STATE_DIR: tempDir,
OPENCLAW_AGENT_DIR: agentDir,
PI_CODING_AGENT_DIR: agentDir,
},
async () => {
const oauthDir = path.join(tempDir, "credentials");
await fs.mkdir(oauthDir, { recursive: true, mode: 0o700 });
saveSealedJsonFile(path.join(oauthDir, "oauth.json"), {
"openai-codex": oauthFixture,
});
const store = ensureAuthProfileStore(process.env.OPENCLAW_AGENT_DIR, {
allowKeychainPrompt: false,
});
expect(store.profiles["openai-codex:default"]).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: oauthFixture.access,
refresh: oauthFixture.refresh,
});
},
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("suggests openai-codex when only Codex OAuth is configured", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));

View File

@ -79,6 +79,14 @@ async function ensureSessionHeader(params: {
});
}
async function hardenSessionFilePermissions(sessionFile: string): Promise<void> {
try {
await fs.promises.chmod(sessionFile, 0o600);
} catch {
// Best-effort on platforms without POSIX chmod support.
}
}
export async function appendAssistantMessageToSessionTranscript(params: {
agentId?: string;
sessionKey: string;
@ -152,6 +160,7 @@ export async function appendAssistantMessageToSessionTranscript(params: {
stopReason: "stop",
timestamp: Date.now(),
});
await hardenSessionFilePermissions(sessionFile);
emitSessionTranscriptUpdate(sessionFile);
return { ok: true, sessionFile };

View File

@ -19,11 +19,14 @@ type WsEventHandlers = {
};
class MockWebSocket {
static readonly OPEN = 1;
static readonly CLOSED = 3;
private openHandlers: WsEventHandlers["open"][] = [];
private messageHandlers: WsEventHandlers["message"][] = [];
private closeHandlers: WsEventHandlers["close"][] = [];
private errorHandlers: WsEventHandlers["error"][] = [];
readonly sent: string[] = [];
readyState = MockWebSocket.CLOSED;
constructor(_url: string, _options?: unknown) {
wsInstances.push(this);
@ -59,6 +62,7 @@ class MockWebSocket {
}
emitOpen(): void {
this.readyState = MockWebSocket.OPEN;
for (const handler of this.openHandlers) {
handler();
}
@ -71,6 +75,7 @@ class MockWebSocket {
}
emitClose(code: number, reason: string): void {
this.readyState = MockWebSocket.CLOSED;
for (const handler of this.closeHandlers) {
handler(code, Buffer.from(reason));
}
@ -438,4 +443,30 @@ describe("GatewayClient connect auth payload", () => {
});
client.stop();
});
it("times out pending requests and cleans them up", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
requestTimeoutMs: 25,
});
client.start();
const ws = getLatestWs();
ws.emitOpen();
const pending = client.request("health.check");
const observed = pending.catch((err) => err);
await vi.advanceTimersByTimeAsync(25);
await expect(observed).resolves.toMatchObject({
message: "gateway request timeout for health.check",
});
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0);
client.stop();
} finally {
vi.useRealTimers();
}
});
});

View File

@ -39,12 +39,14 @@ type Pending = {
resolve: (value: unknown) => void;
reject: (err: unknown) => void;
expectFinal: boolean;
cleanup?: () => void;
};
export type GatewayClientOptions = {
url?: string; // ws://127.0.0.1:18789
connectDelayMs?: number;
tickWatchMinIntervalMs?: number;
requestTimeoutMs?: number;
token?: string;
deviceToken?: string;
password?: string;
@ -442,6 +444,7 @@ export class GatewayClient {
private flushPendingErrors(err: Error) {
for (const [, p] of this.pending) {
p.cleanup?.();
p.reject(err);
}
this.pending.clear();
@ -501,7 +504,7 @@ export class GatewayClient {
async request<T = Record<string, unknown>>(
method: string,
params?: unknown,
opts?: { expectFinal?: boolean },
opts?: { expectFinal?: boolean; timeoutMs?: number },
): Promise<T> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("gateway not connected");
@ -514,14 +517,46 @@ export class GatewayClient {
);
}
const expectFinal = opts?.expectFinal === true;
const rawTimeoutMs = opts?.timeoutMs ?? this.opts.requestTimeoutMs;
const timeoutMs =
typeof rawTimeoutMs === "number" && Number.isFinite(rawTimeoutMs)
? Math.max(1, Math.min(300_000, rawTimeoutMs))
: 30_000;
const p = new Promise<T>((resolve, reject) => {
let timeout: NodeJS.Timeout | null = setTimeout(() => {
timeout = null;
this.pending.delete(id);
reject(new Error(`gateway request timeout for ${method}`));
}, timeoutMs);
timeout.unref?.();
const cleanup = () => {
if (!timeout) {
return;
}
clearTimeout(timeout);
timeout = null;
};
this.pending.set(id, {
resolve: (value) => resolve(value as T),
reject,
resolve: (value) => {
cleanup();
resolve(value as T);
},
reject: (err) => {
cleanup();
reject(err);
},
expectFinal,
cleanup,
});
});
this.ws.send(JSON.stringify(frame));
try {
this.ws.send(JSON.stringify(frame));
} catch (err) {
const pending = this.pending.get(id);
pending?.cleanup?.();
this.pending.delete(id);
throw err;
}
return p;
}
}

View File

@ -0,0 +1,119 @@
import { randomBytes, createCipheriv, createDecipheriv, scryptSync } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
const SEALED_JSON_PREFIX = "openclaw-sealed-json-v1:";
type SealedJsonEnvelope = {
v: 1;
alg: "aes-256-gcm";
salt: string;
iv: string;
tag: string;
ciphertext: string;
};
export class SealedJsonPassphraseRequiredError extends Error {
constructor(pathname: string) {
super(
`Encrypted OpenClaw auth store at ${pathname} requires OPENCLAW_PASSPHRASE to be set before it can be read.`,
);
this.name = "SealedJsonPassphraseRequiredError";
}
}
function resolvePassphrase(env: NodeJS.ProcessEnv = process.env): string | null {
const value = env.OPENCLAW_PASSPHRASE?.trim();
return value ? value : null;
}
function toBase64(value: Buffer): string {
return value.toString("base64");
}
function fromBase64(value: string): Buffer {
return Buffer.from(value, "base64");
}
function deriveKey(passphrase: string, salt: Buffer): Buffer {
return scryptSync(passphrase, salt, 32);
}
function sealUtf8(plaintext: string, passphrase: string): string {
const salt = randomBytes(16);
const iv = randomBytes(12);
const key = deriveKey(passphrase, salt);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const envelope: SealedJsonEnvelope = {
v: 1,
alg: "aes-256-gcm",
salt: toBase64(salt),
iv: toBase64(iv),
tag: toBase64(cipher.getAuthTag()),
ciphertext: toBase64(ciphertext),
};
return `${SEALED_JSON_PREFIX}${JSON.stringify(envelope)}\n`;
}
function unsealUtf8(raw: string, passphrase: string): string {
if (!raw.startsWith(SEALED_JSON_PREFIX)) {
return raw;
}
const payload = raw.slice(SEALED_JSON_PREFIX.length).trim();
const envelope = JSON.parse(payload) as Partial<SealedJsonEnvelope>;
if (
envelope.v !== 1 ||
envelope.alg !== "aes-256-gcm" ||
typeof envelope.salt !== "string" ||
typeof envelope.iv !== "string" ||
typeof envelope.tag !== "string" ||
typeof envelope.ciphertext !== "string"
) {
throw new Error("invalid sealed json envelope");
}
const key = deriveKey(passphrase, fromBase64(envelope.salt));
const decipher = createDecipheriv("aes-256-gcm", key, fromBase64(envelope.iv));
decipher.setAuthTag(fromBase64(envelope.tag));
return Buffer.concat([
decipher.update(fromBase64(envelope.ciphertext)),
decipher.final(),
]).toString("utf8");
}
export function loadSealedJsonFile(
pathname: string,
env: NodeJS.ProcessEnv = process.env,
): unknown {
if (!fs.existsSync(pathname)) {
return undefined;
}
const raw = fs.readFileSync(pathname, "utf8");
if (!raw.startsWith(SEALED_JSON_PREFIX)) {
return JSON.parse(raw) as unknown;
}
const passphrase = resolvePassphrase(env);
if (!passphrase) {
throw new SealedJsonPassphraseRequiredError(pathname);
}
return JSON.parse(unsealUtf8(raw, passphrase)) as unknown;
}
export function saveSealedJsonFile(
pathname: string,
data: unknown,
env: NodeJS.ProcessEnv = process.env,
): void {
const dir = path.dirname(pathname);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
const plaintext = `${JSON.stringify(data, null, 2)}\n`;
const passphrase = resolvePassphrase(env);
fs.writeFileSync(pathname, passphrase ? sealUtf8(plaintext, passphrase) : plaintext, "utf8");
fs.chmodSync(pathname, 0o600);
}
export function isSealedJsonText(raw: string): boolean {
return raw.startsWith(SEALED_JSON_PREFIX);
}