mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-01 12:21:25 +00:00
Auth: support sealed auth store files
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
|
||||
119
src/infra/sealed-json-file.ts
Normal file
119
src/infra/sealed-json-file.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user