/** * Atomic multi-file auth state for Baileys. * * Replaces Baileys' useMultiFileAuthState, whose writeData is a bare fs.writeFile * (truncate-then-write, no rename). A process death between the truncate and the * write leaves a 0-byte creds.json; on the next boot Baileys' readData swallows the * JSON.parse error and silently mints a FRESH identity — the session looks "logged * out" and the user is forced to re-scan the QR even though WhatsApp never unlinked * the device. creds.update fires constantly while connected (key rotations, * app-state sync), so that corruption window recurs for the whole session. * * Hardening over upstream: * - Atomic writes: every file is written to a temp name then rename()d into place, * so a kill at any moment leaves the old or the new content — never a truncated file. * - creds.json is mirrored to creds.json.bak on every save; on load, a missing or * corrupt creds.json is restored from the backup instead of re-initializing. * - A corrupt creds.json with no usable backup is quarantined (renamed) so * hasCredentials() stops treating the dead session as linked. */ import { BufferJSON, initAuthCreds, proto } from '@whiskeysockets/baileys'; import type { AuthenticationCreds, AuthenticationState, SignalDataTypeMap } from '@whiskeysockets/baileys'; import { mkdir, readFile, rename, unlink, writeFile } from 'fs/promises'; import fs from 'fs'; import path from 'path'; import { log } from '../../shared/logger.js'; /** Serializes reads/writes per file path (Baileys events fire concurrently). In-process * only — cross-process safety comes from the atomic rename, not from this. */ const fileQueues = new Map>(); function queued(file: string, task: () => Promise): Promise { const prev = fileQueues.get(file) || Promise.resolve(); const next = prev.then(task, task); // Store a value-free tail (a resolved read would otherwise pin the file's contents // in memory forever) and drop the entry once its queue drains. const tail = next.then(() => undefined, () => undefined); fileQueues.set(file, tail); void tail.then(() => { if (fileQueues.get(file) === tail) fileQueues.delete(file); }); return next; } /** Await everything currently queued for an auth folder — drained on disconnect so a * process exit right after can't clip a signal-key or creds write. */ export function flushAuthWrites(folder: string): Promise { const prefix = folder.endsWith(path.sep) ? folder : folder + path.sep; const tails: Promise[] = []; for (const [file, tail] of fileQueues) { if (file.startsWith(prefix)) tails.push(tail); } return Promise.all(tails).then(() => undefined); } /** Same name mangling as upstream so existing auth dirs keep working as-is. */ const fixFileName = (file: string) => file.replace(/\//g, '__').replace(/:/g, '-'); /** True when the parsed object looks like real Baileys credentials (not null/empty/garbage). */ function isValidCreds(creds: unknown): creds is AuthenticationCreds { const c = creds as AuthenticationCreds | null; return !!( c && typeof c === 'object' && c.noiseKey && c.signedIdentityKey && c.signedPreKey && typeof c.registrationId === 'number' ); } async function writeAtomic(filePath: string, data: string): Promise { const tmp = `${filePath}.${process.pid}.tmp`; try { await writeFile(tmp, data); await rename(tmp, filePath); } catch (err) { try { await unlink(tmp); } catch {} throw err; } } export interface AtomicAuthState { state: AuthenticationState; saveCreds: () => Promise; /** creds.json was missing/corrupt but recovered from creds.json.bak */ restoredFromBackup: boolean; /** No usable credentials found — a brand-new identity was initialized (pairing needed) */ freshIdentity: boolean; } export async function useAtomicMultiFileAuthState(folder: string): Promise { await mkdir(folder, { recursive: true }); const filePath = (file: string) => path.join(folder, fixFileName(file)); // Sweep temp files orphaned by a previous process death mid-write try { for (const f of fs.readdirSync(folder)) { if (f.endsWith('.tmp')) { try { fs.unlinkSync(path.join(folder, f)); } catch {} } } } catch {} const readData = async (file: string): Promise => { try { const data = await queued(filePath(file), () => readFile(filePath(file), { encoding: 'utf-8' })); return JSON.parse(data, BufferJSON.reviver); } catch { return null; } }; const writeData = (data: unknown, file: string): Promise => { // Snapshot synchronously — the creds object mutates while writes are queued const json = JSON.stringify(data, BufferJSON.replacer); return queued(filePath(file), () => writeAtomic(filePath(file), json)); }; const removeData = (file: string): Promise => queued(filePath(file), async () => { try { await unlink(filePath(file)); } catch {} }); // ── Load creds, recovering from backup when the primary is missing/corrupt ── let creds: AuthenticationCreds | null = await readData('creds.json'); let restoredFromBackup = false; if (!isValidCreds(creds)) { const backup = await readData('creds.json.bak'); if (isValidCreds(backup)) { creds = backup; restoredFromBackup = true; await writeData(backup, 'creds.json'); log.warn('[whatsapp] creds.json was missing or corrupt — restored from creds.json.bak'); } else { if (fs.existsSync(filePath('creds.json'))) { // Present but unreadable and no usable backup: quarantine it so the dead // session isn't half-trusted, and start a clean pairing flow. const quarantine = filePath(`creds.json.corrupt-${Date.now()}`); try { await rename(filePath('creds.json'), quarantine); } catch {} log.warn(`[whatsapp] creds.json is corrupt with no backup — quarantined as ${path.basename(quarantine)}; re-link required`); } // The corrupt backup must go too, or hasValidCredsFile() keeps presenting the // dead session as "linked" on every future boot. if (fs.existsSync(filePath('creds.json.bak'))) { try { await rename(filePath('creds.json.bak'), filePath(`creds.json.bak.corrupt-${Date.now()}`)); } catch {} } creds = null; } } const freshIdentity = !creds; if (freshIdentity) { // Any key material on disk is bound to a dead identity — sweep it so the fresh // pairing starts clean instead of tripping over stale sessions and pre-keys. // (Quarantined creds files are kept for forensics.) try { for (const f of fs.readdirSync(folder)) { if (f === 'creds.json' || f.includes('.corrupt-')) continue; try { fs.unlinkSync(path.join(folder, f)); } catch {} } } catch {} } const liveCreds: AuthenticationCreds = creds || initAuthCreds(); return { state: { creds: liveCreds, keys: { get: async (type, ids) => { const data: { [_: string]: SignalDataTypeMap[typeof type] } = {}; await Promise.all( ids.map(async (id) => { let value = await readData(`${type}-${id}.json`); if (type === 'app-state-sync-key' && value) { value = proto.Message.AppStateSyncKeyData.fromObject(value); } data[id] = value; }), ); return data; }, set: async (data) => { const tasks: Promise[] = []; for (const category in data) { const entries = data[category as keyof SignalDataTypeMap]; if (!entries) continue; for (const id in entries) { const value = entries[id]; const file = `${category}-${id}.json`; tasks.push(value ? writeData(value, file) : removeData(file)); } } await Promise.all(tasks); }, }, }, saveCreds: async () => { // One snapshot for both files so primary and backup are always identical const json = JSON.stringify(liveCreds, BufferJSON.replacer); await queued(filePath('creds.json'), () => writeAtomic(filePath('creds.json'), json)); await queued(filePath('creds.json.bak'), () => writeAtomic(filePath('creds.json.bak'), json)); }, restoredFromBackup, freshIdentity, }; } /** Sync check used by status endpoints: a linked session exists when creds.json (or its * backup) is present and non-empty. A 0-byte creds.json — the artifact of an interrupted * legacy write — no longer counts as "linked". */ export function hasValidCredsFile(folder: string): boolean { for (const name of ['creds.json', 'creds.json.bak']) { try { if (fs.statSync(path.join(folder, name)).size > 0) return true; } catch {} } return false; }