diff --git a/src/agents/auth-profiles.store.save.test.ts b/src/agents/auth-profiles.store.save.test.ts index 292921feaf1..1e01ab6418a 100644 --- a/src/agents/auth-profiles.store.save.test.ts +++ b/src/agents/auth-profiles.store.save.test.ts @@ -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 }); + } + }); }); diff --git a/src/agents/auth-profiles/paths.ts b/src/agents/auth-profiles/paths.ts index 78167334f92..65fb0eeccac 100644 --- a/src/agents/auth-profiles/paths.ts +++ b/src/agents/auth-profiles/paths.ts @@ -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); } diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 0fa050e55ec..d7a125de693 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -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); } diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index e2d9d09ab12..454d226f612 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -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-")); diff --git a/src/infra/sealed-json-file.ts b/src/infra/sealed-json-file.ts new file mode 100644 index 00000000000..ebab0fafa8e --- /dev/null +++ b/src/infra/sealed-json-file.ts @@ -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; + 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); +}