mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 07:23:14 +00:00
294 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
}
|