Files
webadderall ba4947dccc Fix Recordly 2.0 part 6 follow-up review findings
Preserve existing PR history and integrate validated second-cycle fixes.
2026-11-07 20:52:53 +11:00

294 lines
11 KiB
TypeScript

import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { app, type IpcMainInvokeEvent, ipcMain, safeStorage } from "electron";
import { withAuthStorageTransaction } from "./authStorageTransactionLock";
import { getPackagedRendererBaseUrl } from "./rendererServer";
const MAX_VALUE_LENGTH = 256 * 1024;
const MAX_STORE_LENGTH = 1024 * 1024;
const MAX_PENDING_CONSENTS = 5;
const MAX_CONSENT_RETRIES = 5;
const MAX_KEYS = 16 + MAX_CONSENT_RETRIES;
const MAX_CONSENT_LENGTH = 8192;
const COMPLETED_CALLBACK_KEY = /^sb-[a-z0-9-]{1,100}-auth-token-completed-callback$/;
const PENDING_CONSENT_KEY = /^sb-[a-z0-9-]{1,100}-auth-token-signup-consent-[A-Za-z0-9_-]{43}$/;
const CONSENT_RETRY_KEY =
/^sb-[a-z0-9-]{1,100}-auth-token-signup-consent-retry:[A-Za-z0-9_-]{1,128}$/;
// Supabase stores a bounded index and one verifier per pending PKCE flow.
const AUTH_KEY =
/^sb-[a-z0-9-]{1,100}-auth-token(?:-code-verifier|-user|-flows-code-verifier|-flow-[A-Za-z0-9_-]{8,64}-code-verifier|-signup-consent-[A-Za-z0-9_-]{43}|-signup-consent-retry:[A-Za-z0-9_-]{1,128}|-completed-callback)?$/;
type AuthEntries = Record<string, string>;
export type AuthFlowStorageHooks = {
prepareSet: (
event: IpcMainInvokeEvent,
key: string,
persistedKeys?: readonly string[],
) => Promise<(() => Promise<void>) | undefined>;
didRemove: (event: IpcMainInvokeEvent, key: string) => Promise<void>;
};
let pending = Promise.resolve();
let completedCallback: { key: string; fingerprint: string } | undefined;
export function encryptionAvailable(): boolean {
return (
safeStorage.isEncryptionAvailable() &&
(process.platform !== "linux" || safeStorage.getSelectedStorageBackend() !== "basic_text")
);
}
function assertTrustedRenderer(event: IpcMainInvokeEvent): void {
if (!event.senderFrame || event.senderFrame !== event.sender.mainFrame) {
throw new Error("Authentication storage requires the app's main frame.");
}
const candidate = new URL(event.senderFrame.url);
const trusted = [
process.env.VITE_DEV_SERVER_URL,
getPackagedRendererBaseUrl(),
pathToFileURL(path.join(process.env.APP_ROOT || app.getAppPath(), "dist", "index.html"))
.href,
].filter((value): value is string => Boolean(value));
if (
candidate.username ||
candidate.password ||
!trusted.some((value) => {
const base = new URL(value);
return candidate.origin === base.origin && candidate.pathname === base.pathname;
})
) {
throw new Error("Authentication storage is only available to Recordly.");
}
}
function assertKey(key: unknown): asserts key is string {
if (typeof key !== "string" || !AUTH_KEY.test(key)) {
throw new Error("Invalid authentication storage key.");
}
}
function validateEntries(value: unknown): AuthEntries {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Invalid authentication storage data.");
}
const values = Object.entries(value);
if (values.length > MAX_KEYS) throw new Error("Authentication storage is too large.");
const result: AuthEntries = Object.create(null);
let pendingConsents = 0;
let consentRetries = 0;
for (const [key, item] of values) {
if (PENDING_CONSENT_KEY.test(key) && ++pendingConsents > MAX_PENDING_CONSENTS) {
throw new Error("Too many pending signup acknowledgements.");
}
if (CONSENT_RETRY_KEY.test(key) && ++consentRetries > MAX_CONSENT_RETRIES) {
throw new Error("Too many signup acknowledgement retries.");
}
assertKey(key);
if (typeof item !== "string" || item.length > MAX_VALUE_LENGTH) {
throw new Error("Invalid authentication storage value.");
}
if (COMPLETED_CALLBACK_KEY.test(key) && !/^[0-9a-f]{64}$/.test(item)) {
throw new Error("Invalid completed callback fingerprint.");
}
if (
(PENDING_CONSENT_KEY.test(key) || CONSENT_RETRY_KEY.test(key)) &&
item.length > MAX_CONSENT_LENGTH
) {
throw new Error("Pending signup acknowledgement is too large.");
}
result[key] = item;
}
if (JSON.stringify(result).length > MAX_STORE_LENGTH) {
throw new Error("Authentication storage is too large.");
}
return result;
}
function readStoredValue(entries: AuthEntries, key: string): string | null {
const prefix = /^(sb-[a-z0-9-]{1,100}-auth-token)-flows-code-verifier$/.exec(key)?.[1];
if (!prefix) return entries[key] ?? null;
const present = new Set<string>();
for (const candidate of Object.keys(entries)) {
const slot =
/^(sb-[a-z0-9-]{1,100}-auth-token)-flow-([A-Za-z0-9_-]{8,64})-code-verifier$/.exec(
candidate,
);
if (slot?.[1] === prefix) present.add(slot[2]);
}
let stored: unknown;
try {
stored = JSON.parse(entries[key] ?? "null");
} catch {
// The index is bookkeeping; exact persisted verifier slots are authoritative.
}
const ids = new Set<string>(
Array.isArray(stored)
? stored.filter((id) => typeof id === "string" && present.has(id))
: [],
);
for (const id of present) ids.add(id);
// SDK starts race their index read/modify/write. Repair reads without deleting
// any active slot; only the SDK's normal explicit ring removals evict verifiers.
return ids.size || key in entries ? JSON.stringify([...ids]) : null;
}
function storagePath(): string {
return path.join(app.getPath("userData"), "private-auth", "session.enc");
}
async function load(): Promise<{ entries: AuthEntries; persisted: boolean }> {
if (!encryptionAvailable()) throw new Error("Secure authentication storage is unavailable.");
try {
const filePath = storagePath();
const stat = await fs.stat(filePath);
if (stat.size > MAX_STORE_LENGTH * 8)
throw new Error("Authentication storage is too large.");
const payload = JSON.parse(safeStorage.decryptString(await fs.readFile(filePath)));
if (payload.version !== 1) throw new Error("Unsupported authentication storage version.");
return { entries: validateEntries(payload.entries), persisted: true };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
// Keep an unreadable store intact; never replace it with an empty session.
throw new Error(
"Could not unlock saved authentication. Restart Recordly and try again.",
);
}
return { entries: Object.create(null) as AuthEntries, persisted: false };
}
}
async function save(next: AuthEntries): Promise<void> {
const validated = validateEntries(next);
const encrypted = safeStorage.encryptString(JSON.stringify({ version: 1, entries: validated }));
const filePath = storagePath();
const directory = path.dirname(filePath);
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
if (process.platform !== "win32") await fs.chmod(directory, 0o700);
const temporary = path.join(directory, `${randomUUID()}.tmp`);
try {
const file = await fs.open(temporary, "wx", 0o600);
try {
await file.writeFile(encrypted);
await file.sync();
} finally {
await file.close();
}
await fs.rename(temporary, filePath);
} finally {
await fs.rm(temporary, { force: true }).catch(() => {
// A cleanup failure must not mask the original storage error.
});
}
}
function serialize<T>(operation: () => Promise<T>): Promise<T> {
const result = pending.then(operation);
pending = result.then(
() => undefined,
() => undefined,
);
return result;
}
function transaction<T>(operation: () => Promise<T>): Promise<T> {
return withAuthStorageTransaction(
path.join(path.dirname(storagePath()), "transactions"),
operation,
);
}
export function readNativeAuthStorageValue(key: string): Promise<string | null> {
assertKey(key);
return serialize(() => transaction(async () => readStoredValue((await load()).entries, key)));
}
export function registerAuthStorageHandlers(flowHooks?: AuthFlowStorageHooks): void {
ipcMain.handle("auth-storage:available", (event) => {
assertTrustedRenderer(event);
return encryptionAvailable();
});
ipcMain.handle("auth-storage:migrate", (event, snapshot: unknown) => {
assertTrustedRenderer(event);
const validated = validateEntries(snapshot);
return serialize(() =>
transaction(async () => {
const current = await load();
if (!current.persisted) await save(validated);
}),
);
});
ipcMain.handle("auth-storage:get", (event, key: unknown) => {
assertTrustedRenderer(event);
assertKey(key);
return serialize(async () =>
completedCallback?.key === key
? completedCallback.fingerprint
: transaction(async () => readStoredValue((await load()).entries, key)),
);
});
ipcMain.handle("auth-storage:set", (event, key: unknown, value: unknown) => {
assertTrustedRenderer(event);
assertKey(key);
const validated = validateEntries({ [key]: value });
return serialize(async () => {
// This process's queue spans snapshot, registration and commit/rollback.
// Only queued prepareSet calls add owned routes, so no earlier own route
// can still be preparing its verifier when this snapshot is captured.
const persistedKeys =
flowHooks &&
/^sb-[a-z0-9-]{1,100}-auth-token-flow-[A-Za-z0-9_-]{8,64}-code-verifier$/.test(key)
? await transaction(async () => Object.keys((await load()).entries))
: undefined;
const rollback = await flowHooks?.prepareSet(event, key, persistedKeys);
if (COMPLETED_CALLBACK_KEY.test(key)) {
// Keep one non-secret completion marker available to all our windows even
// if optional bookkeeping cannot be persisted after a successful exchange.
completedCallback = { key, fingerprint: validated[key] };
}
try {
return await transaction(async () => {
const next = { ...(await load()).entries, ...validated };
if (completedCallback)
for (const candidate of Object.keys(next)) {
if (
candidate !== completedCallback.key &&
COMPLETED_CALLBACK_KEY.test(candidate)
)
delete next[candidate];
}
const consents = Object.keys(next).filter((item) =>
PENDING_CONSENT_KEY.test(item),
);
for (const expired of consents.slice(0, -MAX_PENDING_CONSENTS))
delete next[expired];
const retries = Object.keys(next).filter((item) =>
CONSENT_RETRY_KEY.test(item),
);
for (const expired of retries.slice(0, -MAX_CONSENT_RETRIES))
delete next[expired];
await save(next);
});
} catch (error) {
await rollback?.().catch(() => undefined);
throw error;
}
});
});
ipcMain.handle("auth-storage:remove", (event, key: unknown) => {
assertTrustedRenderer(event);
assertKey(key);
return serialize(async () => {
await transaction(async () => {
const next = { ...(await load()).entries };
delete next[key];
await save(next);
if (completedCallback?.key === key) completedCallback = undefined;
});
// The verifier is already removed durably. Optional route cleanup must
// not turn a completed server exchange into a storage failure; a stale
// route still cannot deliver without that exact verifier.
await flowHooks?.didRemove(event, key).catch(() => undefined);
});
});
}