fix(chatgpt): use private JSON credential storage without Keychain

This commit is contained in:
amosblomqvist
2026-10-24 18:41:03 +03:00
parent 50a2db775d
commit 24d1dda07d
7 changed files with 215 additions and 64 deletions
+72 -29
View File
@@ -7,7 +7,7 @@ import os from "node:os";
import path from "node:path";
import { exportJWK, generateKeyPair, SignJWT } from "jose";
const require = createRequire(import.meta.url);
const { createChatGPT, encryptedStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs");
const { createChatGPT, fileStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs");
const { privateKey, publicKey } = await generateKeyPair("RS256");
const jwk = { ...await exportJWK(publicKey), kid: "scratch-key", alg: "RS256", use: "sig" };
const tokenUrl = "https://auth.openai.com/api/accounts/oauth/token";
@@ -16,14 +16,14 @@ async function signed(client: string, nonce: string, subject = "scratch-user", o
return new SignJWT({ nonce, email: "dummy@example.invalid", ...overrides }).setProtectedHeader({ alg: "RS256", kid: "scratch-key" }).setIssuer("https://auth.openai.com").setSubject(subject).setAudience(client).setIssuedAt().setExpirationTime("1h").sign(privateKey);
}
function deferred<T = void>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { resolve, promise }; }
function fixture(options: { state?: any; expires?: number; timeoutMs?: number; token?: (form: URLSearchParams, url: URL) => Promise<Response>; write?: (next: any) => Promise<void>; secure?: () => void; change?: (push: any) => void; provider?: (target: string, init: RequestInit) => Promise<Response | undefined> } = {}) {
function fixture(options: { state?: any; expires?: number; timeoutMs?: number; token?: (form: URLSearchParams, url: URL) => Promise<Response>; write?: (next: any) => Promise<void>; read?: () => void; change?: (push: any) => void; provider?: (target: string, init: RequestInit) => Promise<Response | undefined> } = {}) {
let saved = structuredClone(options.state ?? initial());
let url!: URL;
let writeCount = 0;
const changed = deferred();
const requests: { url: string; form?: URLSearchParams }[] = [];
const service = createChatGPT({
store: { secure() { options.secure?.(); }, async read() { return structuredClone(saved); }, async write(next: any) { writeCount++; await options.write?.(next); saved = structuredClone(next); } },
store: { async read() { options.read?.(); return structuredClone(saved); }, async write(next: any) { writeCount++; await options.write?.(next); saved = structuredClone(next); } },
openExternal: async (value: string) => { url = new URL(value); },
timeoutMs: options.timeoutMs ?? 5000,
onChange: (push: any) => { options.change?.(push); if (!push.status.pending) changed.resolve(); },
@@ -153,7 +153,7 @@ test("missing plan scope retains verified registration but refuses inference and
} finally { f.service.close(); }
});
test("durable encrypted save failure never activates a pending login", async () => {
test("durable file save failure never activates a pending login", async () => {
const f = fixture({ write: async () => { throw new Error("scratch disk failure containing no real data"); } });
try {
await f.service.login(); await f.callback(); await f.changed.promise;
@@ -162,6 +162,40 @@ test("durable encrypted save failure never activates a pending login", async ()
} finally { f.service.close(); }
});
test("rotated-token persistence failure invalidates existing leases and never reloads the stale saved token", async () => {
let failWrite = false, reads = 0, refreshes = 0;
const pushes: any[] = [];
const f = await loggedIn({ read: () => { reads++; }, change: (push) => pushes.push(push),
write: async () => { if (failWrite) throw Object.assign(new Error("storage"), { code: "storage" }); },
token: async (form, url) => {
if (form.get("grant_type") === "refresh_token") {
refreshes++;
return Response.json({ access_token: "rotated-access", refresh_token: "rotated-refresh", token_type: "Bearer", expires_in: 3600 });
}
return Response.json({ access_token: "old-access", refresh_token: "old-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") });
},
});
try {
const catalog = await f.service.models();
const expected = { expectedAccountId: catalog.accountId, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" };
await f.rawLease(false, expected);
// Advance time rather than mutating the service's private state.
const now = Date.now; Date.now = () => now() + 3600000;
try {
failWrite = true;
await assert.rejects(f.rawLease(false, expected), /storage/);
} finally { Date.now = now; }
assert.equal(refreshes, 1);
assert.equal(f.saved.registrations[0].tokens.refreshToken, "old-refresh", "failed write leaves only the stale predecessor on disk");
assert(pushes.some((push) => push.invalidate === "all" && push.stamp.epoch > catalog.epoch));
failWrite = false;
await f.service.status(); await f.service.models(); f.service.resume(); await f.service.consent(true);
await assert.rejects(f.rawLease(false, expected)); await assert.rejects(f.rawLease(true, expected));
assert.equal((await f.service.status()).accounts[0].connected, false);
assert.equal(reads, 1); assert.equal(refreshes, 1, "no stale-token retry after storage recovers");
} finally { f.service.close(); }
});
test("serialized concurrent refresh rotates once and persists before leases", async () => {
let refreshes = 0;
const f = await loggedIn({ expires: 1, token: async (form, url) => {
@@ -208,28 +242,33 @@ test("account catalog is scoped, visibility filtered, and cleared on disconnect"
} finally { f.service.close(); }
});
test("safeStorage refuses basic_text/unavailable; encrypted file owner-only, atomic, no plaintext fallback", async () => {
test("private JSON file round-trips global state; creation and atomic replacement are owner-only", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-storage-"));
const file = path.join(dir, "credentials.enc.json");
const secure = { isEncryptionAvailable: () => true, getSelectedStorageBackend: () => "keychain", encryptString: (s: string) => Buffer.from(s.split("").reverse().join("")), decryptString: (b: Buffer) => b.toString().split("").reverse().join("") };
const file = path.join(dir, "private", "chatgpt-credentials.json");
try {
const store = encryptedStore(file, secure);
const data = { ...initial(), marker: "scratch-secret-never-plaintext" };
const store = fileStore(file);
assert.equal(await store.read(), null);
const data = { ...initial(), allowBackgroundUse: false };
await store.write(data);
assert(!(await fs.readFile(file, "utf8")).includes(data.marker));
assert.deepEqual(JSON.parse(await fs.readFile(file, "utf8")), data, "ordinary JSON, not an encrypted envelope");
assert.deepEqual(await store.read(), data);
assert.equal((await fs.stat(path.dirname(file))).mode & 0o777, 0o700);
assert.equal((await fs.stat(file)).mode & 0o777, 0o600);
assert.deepEqual(await fs.readdir(dir), ["credentials.enc.json"]);
for (const backend of [{ ...secure, getSelectedStorageBackend: () => "basic_text" }, { ...secure, isEncryptionAvailable: () => false }]) {
await assert.rejects(encryptedStore(file, backend).read(), /secure_storage/);
await assert.rejects(encryptedStore(file, backend).write(data), /secure_storage/);
}
assert(!allowedAuthEndpoint("https://auth.openai.com.evil.invalid/revoke"));
assert(!allowedAuthEndpoint("http://127.0.0.1/revoke"));
assert(!allowedAuthEndpoint("https://auth.openai.com@evil.invalid/revoke"));
await fs.chmod(file, 0o644);
await store.write({ ...data, allowBackgroundUse: true });
assert.equal((await fs.stat(file)).mode & 0o777, 0o600, "replacement never inherits a permissive old mode");
assert.equal((await store.read()).allowBackgroundUse, true);
assert.deepEqual(await fs.readdir(path.dirname(file)), ["chatgpt-credentials.json"]);
} finally { await fs.rm(dir, { recursive: true, force: true }); }
});
test("revocation endpoint remains restricted to the exact secure issuer", () => {
assert(allowedAuthEndpoint("https://auth.openai.com/api/accounts/oauth/revoke"));
assert(!allowedAuthEndpoint("https://auth.openai.com.evil.invalid/revoke"));
assert(!allowedAuthEndpoint("http://127.0.0.1/revoke"));
assert(!allowedAuthEndpoint("https://auth.openai.com@evil.invalid/revoke"));
});
test("invalid rotating replacement is durably revocation-only; verified mapping and prior validated ID survive restart", async () => {
const f = await loggedIn({ expires: 1, token: async (form, url) => form.get("grant_type") === "refresh_token"
? Response.json({ access_token: "never-save-this-access", refresh_token: "likely-latest-revoke-handle", id_token: "invalid.signature.jwt", token_type: "Bearer", expires_in: 3600 })
@@ -255,39 +294,43 @@ test("invalid rotating replacement is durably revocation-only; verified mapping
assert.equal(restarted.requests.find((r) => r.form?.has("token_type_hint"))!.form!.get("token"), "likely-latest-revoke-handle");
assert.equal(restarted.saved.registrations[0].reconnectRequired, true);
await restarted.service.login(restricted.id); await restarted.callback(); await restarted.changed.promise;
// Wait for callback's asynchronous encrypted activation, not the prior status push.
// Wait for callback's asynchronous durable activation, not the prior status push.
for (let i = 0; i < 30 && restarted.saved.registrations[0].reconnectRequired; i++) await new Promise((resolve) => setImmediate(resolve));
assert.equal(restarted.saved.registrations[0].reconnectRequired, undefined);
} finally { restarted.service.close(); }
} finally { f.service.close(); }
});
test("detected secure backend loss emits full invalidation, denies all leases and cannot be repaired by consent/resume", async () => {
let lost = false; const pushes: any[] = [];
const f = await loggedIn({ secure: () => { if (lost) throw Object.assign(new Error("secure_storage"), { code: "secure_storage" }); }, change: (push) => pushes.push(push) });
test("observed file write failure invalidates all work; status, consent and resume cannot reload stale credentials or repair authority", async () => {
let lost = false, reads = 0; const pushes: any[] = [];
const f = await loggedIn({ read: () => { reads++; }, write: async () => { if (lost) throw Object.assign(new Error("storage"), { code: "storage" }); }, change: (push) => pushes.push(push) });
try {
await f.service.lease(false); const epoch = f.service.stamp().epoch;
lost = true;
assert.equal((await f.service.status()).available, false);
await assert.rejects(f.service.consent(false), /storage/);
assert(pushes.some((p) => p.invalidate === "all" && p.stamp.epoch > epoch));
await assert.rejects(f.service.lease(false)); await assert.rejects(f.service.lease(true));
lost = false; f.service.resume(); await f.service.consent(true);
await f.service.status(); await f.service.models();
await assert.rejects(f.service.lease(false));
assert.equal((await f.service.status()).accounts[0].connected, false);
assert.equal(reads, 1, "a recovered disk never causes a stale credential reload");
await f.service.login(f.saved.activeId); await f.callback();
for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((resolve) => setImmediate(resolve));
assert.equal((await f.service.status()).accounts[0].connected, true, "verified login plus durable save can explicitly reconnect");
} finally { f.service.close(); }
});
test("strict encrypted schema rejects malformed marker/duplicates/token/active identity without changing original bytes", async () => {
test("strict JSON schema rejects malformed marker/duplicates/token/active identity without changing original bytes", async () => {
const f = await loggedIn(); f.service.close();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-corrupt-"));
const file = path.join(dir, "credentials.enc.json");
const secure = { isEncryptionAvailable: () => true, encryptString: (s: string) => Buffer.from(s), decryptString: (b: Buffer) => b.toString() };
const file = path.join(dir, "chatgpt-credentials.json");
try {
for (const mutate of [(s: any) => s.registrations[0].reconnectRequired = false, (s: any) => s.registrations[0].reconnectRequired = "true", (s: any) => s.registrations.push(s.registrations[0]), (s: any) => s.activeId = "missing", (s: any) => s.registrations[0].tokens.scopes = "not-array", (s: any) => s.registrations[0].tokens.expiresAt = -1, (s: any) => s.allowBackgroundUse = "true", (s: any) => s.allowBackgroundUse = null, (s: any) => s.registrations[0].background = "false"]) {
const state = structuredClone(f.saved); mutate(state);
const bytes = JSON.stringify({ version: 1, encrypted: Buffer.from(JSON.stringify(state)).toString("base64") });
const bytes = JSON.stringify(state);
await fs.writeFile(file, bytes);
const service = createChatGPT({ store: encryptedStore(file, secure), openExternal: () => assert.fail("no OAuth"), fetch: () => assert.fail("no provider") });
const service = createChatGPT({ store: fileStore(file), openExternal: () => assert.fail("no OAuth"), fetch: () => assert.fail("no provider") });
try { assert.equal((await service.status()).available, false); await assert.rejects(service.login()); assert.equal(await fs.readFile(file, "utf8"), bytes); }
finally { service.close(); }
}
@@ -459,7 +502,7 @@ test("disconnect racing refresh revokes the latest rotated handle before forgett
} finally { release.resolve(); f.service.close(); }
});
test("disconnect persistence failure is not reported as local forgetting and leaves encrypted state fail-closed", async () => {
test("disconnect persistence failure is not reported as local forgetting and leaves stored state fail-closed", async () => {
let failWrite = false;
const f = await loggedIn({ write: async () => { if (failWrite) throw Object.assign(new Error("storage"), { code: "storage" }); } });
try {