Auth: support sealed auth store files

This commit is contained in:
Vincent Koc
2026-03-07 09:42:25 -08:00
parent 7f9eaed281
commit bbc3bd9cf2
5 changed files with 219 additions and 10 deletions

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

@@ -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);
}