const fs = require("node:fs"); const path = require("node:path"); const crypto = require("node:crypto"); const BACKUP_DIR_NAME = "vault-backups"; const BACKUP_FILE_PREFIX = "vault-backup-"; const BACKUP_FILE_EXT = ".json"; // The renderer is the untrusted input boundary for this bridge, so every // piece of user-controlled data is validated before it reaches disk or // propagates back into the UI. Keep these limits in sync with the // renderer's `sanitizeLocalVaultBackupMaxCount` constants. const MIN_MAX_COUNT = 1; const MAX_MAX_COUNT = 100; const DEFAULT_MAX_COUNT = 20; // 25 MiB — two orders of magnitude above any realistic vault. A payload // exceeding this is either a runaway test harness or a misbehaving/compromised // renderer; refusing here prevents disk-fill DoS. The vault proper is capped // at a much smaller size elsewhere in the app, so legitimate users never hit // this limit. const MAX_PAYLOAD_BYTES = 25 * 1024 * 1024; const ALLOWED_REASONS = new Set(["app_version_change", "before_restore"]); // Version strings are persisted and surfaced in the Settings UI, so they // must not carry control chars that would break logs, parsing, or // display. Keep alphanumerics + a handful of punctuation that covers // SemVer-ish and prerelease tags. const VERSION_STRING_PATTERN = /^[A-Za-z0-9._+\-]{1,64}$/; function isPlainObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } // Normalize a payload into a form that hashes stably across runs: // - object keys sorted so JSON.stringify output is deterministic // - undefined values dropped (they'd stringify as gaps anyway) // - the TOP-LEVEL `syncedAt` timestamp is zeroed so semantically-equal // payloads produced seconds apart still dedupe. Nested `syncedAt` // fields (e.g. a future per-record mtime) are preserved — zeroing // them would silently collide two semantically-different payloads // into the same fingerprint and cause the version-change / protective // backup dedupe to drop a backup that should have been written. // // INVARIANT: array order is treated as semantically meaningful and is // NOT canonicalized. Every domain array that flows through SyncPayload // (hosts, keys, snippets, identities, portForwardingRules, …) is // produced by a store that iterates its internal `Map`/`Set` in a // stable, insertion-ordered way, so two semantically-equal payloads // built in the same renderer session produce identical orderings. If a // future refactor introduces a non-deterministic iteration source, // fingerprints will flap and the dedupe will miss — sort at the // producer, not here. Sorting inside the hash function would require // choosing a stable key per array type and would silently hide // intentionally-reordered payloads (user dragged a host in the list) // as "the same backup," which would be a safety regression. function normalizePayloadForHash(value, isRoot = true) { if (Array.isArray(value)) { return value.map((item) => normalizePayloadForHash(item, false)); } if (isPlainObject(value)) { const entries = Object.entries(value) .filter(([, item]) => item !== undefined) .sort(([a], [b]) => a.localeCompare(b)); return entries.reduce((acc, [entryKey, entryValue]) => { acc[entryKey] = isRoot && entryKey === "syncedAt" ? 0 : normalizePayloadForHash(entryValue, false); return acc; }, {}); } return value; } function stableStringify(value) { return JSON.stringify(normalizePayloadForHash(value)); } function computePayloadFingerprint(payload) { return crypto .createHash("sha256") .update(stableStringify(payload)) .digest("hex"); } function buildPreview(payload) { return { hostCount: Array.isArray(payload?.hosts) ? payload.hosts.length : 0, keyCount: Array.isArray(payload?.keys) ? payload.keys.length : 0, snippetCount: Array.isArray(payload?.snippets) ? payload.snippets.length : 0, noteCount: Array.isArray(payload?.notes) ? payload.notes.length : 0, identityCount: Array.isArray(payload?.identities) ? payload.identities.length : 0, portForwardingRuleCount: Array.isArray(payload?.portForwardingRules) ? payload.portForwardingRules.length : 0, }; } function toBackupSummary(record) { return { id: record.id, createdAt: record.createdAt, reason: record.reason, syncDataVersion: record.syncDataVersion, sourceAppVersion: record.sourceAppVersion, targetAppVersion: record.targetAppVersion, preview: record.preview, fingerprint: record.fingerprint, }; } // Clamp an unvalidated maxCount to the supported range. Returns // DEFAULT_MAX_COUNT for anything non-finite or non-numeric so callers // without a configured retention still get a sane cap. function sanitizeMaxCount(rawMaxCount) { const numeric = Number(rawMaxCount); if (!Number.isFinite(numeric) || numeric <= 0) return DEFAULT_MAX_COUNT; return Math.max(MIN_MAX_COUNT, Math.min(MAX_MAX_COUNT, Math.floor(numeric))); } function sanitizeReason(rawReason) { // Fall back to the "before_restore" default rather than throwing — the // default is the safer label for an unknown-cause backup, since it // implies "this was taken defensively" in the UI. if (typeof rawReason === "string" && ALLOWED_REASONS.has(rawReason)) { return rawReason; } return "before_restore"; } function sanitizeOptionalVersionString(value) { if (typeof value !== "string") return undefined; const trimmed = value.trim(); if (!trimmed) return undefined; if (!VERSION_STRING_PATTERN.test(trimmed)) return undefined; return trimmed; } // Sync data version is the integer that the CloudSyncManager increments // on each successful cloud sync. Reject anything non-finite, non-positive, // or non-integer so the persisted record only carries meaningful values. function sanitizeOptionalSyncDataVersion(value) { if (typeof value !== "number" || !Number.isFinite(value)) return undefined; if (value < 1) return undefined; return Math.floor(value); } // UTF-8 byte length of a payload's JSON serialization. Earlier revisions // returned `JSON.stringify(payload).length` (UTF-16 code units), which // under-counted by ~3x for non-ASCII vaults — a deck full of CJK snippet // labels would report ~12.5 MiB against the 25 MiB cap when the on-wire // size was actually 25+ MiB. `Buffer.byteLength(..., 'utf8')` gives the // true bytes-on-disk figure. function estimatePayloadSize(payload) { try { return Buffer.byteLength(JSON.stringify(payload), "utf8"); } catch { return Infinity; } } // Error thrown when the platform has no secure storage available. Backups // would contain plaintext credentials (passwords, private keys, passphrases) // in fields that SyncPayload carries unencrypted, so falling back to a // plain-json file on disk would regress the vault's security posture below // what the normal encrypted localStorage vault provides. We refuse rather // than silently weaken the user's protection. class VaultBackupEncryptionUnavailableError extends Error { constructor() { super( "Secure storage is unavailable on this platform; vault backups cannot be created or read safely.", ); this.name = "VaultBackupEncryptionUnavailableError"; this.code = "VAULT_BACKUP_ENCRYPTION_UNAVAILABLE"; } } class VaultBackupTooLargeError extends Error { constructor(size) { super( `Vault backup payload exceeds maximum allowed size (${size} > ${MAX_PAYLOAD_BYTES}).`, ); this.name = "VaultBackupTooLargeError"; this.code = "VAULT_BACKUP_TOO_LARGE"; } } function isSafeStorageAvailable(safeStorage) { return Boolean(safeStorage?.isEncryptionAvailable?.()); } function encodePayload(payload, safeStorage) { if (!isSafeStorageAvailable(safeStorage)) { throw new VaultBackupEncryptionUnavailableError(); } const raw = JSON.stringify(payload); return { encoding: "safeStorage-v1", data: safeStorage.encryptString(raw).toString("base64"), }; } function decodePayload(record, safeStorage) { if (record.payloadEncoding === "safeStorage-v1") { if (!safeStorage?.decryptString || !isSafeStorageAvailable(safeStorage)) { throw new VaultBackupEncryptionUnavailableError(); } const decrypted = safeStorage.decryptString(Buffer.from(record.payloadData, "base64")); return JSON.parse(decrypted); } // Legacy "plain-json-v1" records may exist from an earlier build; read // them once so users can migrate their data, but never write new ones. if (record.payloadEncoding === "plain-json-v1") { return JSON.parse(record.payloadData); } throw new Error(`Unsupported vault backup encoding: ${record.payloadEncoding}`); } // Upper bound for a backup file on disk. The plaintext payload is capped // at MAX_PAYLOAD_BYTES on write; the encrypted-and-base64-encoded record // plus JSON envelope inflates that by ~2x worst case (base64 adds ~33%, // JSON formatting adds some, and the record metadata rounds up). A 2x // multiplier leaves comfortable headroom for legitimate backups while // still rejecting a 100+ MiB file that a user (or attacker) dropped // into the backup directory manually. const MAX_BACKUP_FILE_BYTES = MAX_PAYLOAD_BYTES * 2; async function readBackupRecord(filePath) { // Refuse oversized files BEFORE readFile. `fs.readFile` buffers the // whole file into memory, so an attacker (or a corrupted state) that // places a huge file in the backup dir could OOM the renderer during // listBackups enumeration. Stat-then-read keeps the failure mode to // a cheap rejection. let stat; try { stat = await fs.promises.stat(filePath); } catch (error) { throw new Error(`Unable to stat vault backup ${filePath}: ${error instanceof Error ? error.message : String(error)}`); } if (stat.size > MAX_BACKUP_FILE_BYTES) { throw new VaultBackupTooLargeError(stat.size); } const raw = await fs.promises.readFile(filePath, "utf8"); const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") { throw new Error(`Invalid vault backup record: ${filePath}`); } return parsed; } async function listBackupRecords(dirPath) { await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 }); const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); const records = []; for (const entry of entries) { if (!entry.isFile()) continue; if (!entry.name.startsWith(BACKUP_FILE_PREFIX) || !entry.name.endsWith(BACKUP_FILE_EXT)) continue; const fullPath = path.join(dirPath, entry.name); try { const record = await readBackupRecord(fullPath); records.push({ record, filePath: fullPath }); } catch (error) { console.warn("[vaultBackupBridge] Failed to parse backup:", fullPath, error); } } records.sort((a, b) => { const aTime = Number(a.record.createdAt || 0); const bTime = Number(b.record.createdAt || 0); if (aTime !== bTime) return bTime - aTime; // Stable, deterministic tiebreak when two backups share a millisecond // (rapid successive creates, clock quantization). Without this the // retention trimmer's "delete the oldest" pass is order-dependent and // can drop a different record across list() → prune() passes. const aId = String(a.record.id || ''); const bId = String(b.record.id || ''); return bId.localeCompare(aId); }); return records; } // Delete old backups, trusting the caller-provided `records` list when // supplied to avoid a redundant directory scan. `createBackup` has just // scanned + written, so it passes its freshly-enumerated records through // here. External callers (retention-change UI, trim IPC) rescan. async function pruneBackupRecords(dirPath, maxCount, records = null) { const sanitizedMaxCount = sanitizeMaxCount(maxCount); const sourceRecords = records ?? (await listBackupRecords(dirPath)); const toDelete = sourceRecords.slice(sanitizedMaxCount); let deletedCount = 0; for (const entry of toDelete) { try { await fs.promises.unlink(entry.filePath); deletedCount += 1; } catch (error) { console.warn("[vaultBackupBridge] Failed to delete old backup:", entry.filePath, error); } } return { deletedCount, keptCount: Math.min(sourceRecords.length, sanitizedMaxCount), }; } function createVaultBackupService({ app, safeStorage, shell }) { if (!app?.getPath) { throw new Error("Electron app is unavailable."); } const getBackupDir = () => path.join(app.getPath("userData"), BACKUP_DIR_NAME); // Serialize createBackup so two concurrent calls (version-change backup // running at startup + an explicit protective-before-restore triggered // by the user's click, etc.) observe each other's writes. Without this, // both observers would see an empty directory, compute the same // fingerprint, skip the dedupe, and write two identical files. let createBackupLock = Promise.resolve(); // Monotonically increasing `createdAt` per service instance. `Date.now()` // has 1ms resolution and back-to-back async calls (version-change backup // followed immediately by a protective backup) can land in the same // millisecond, producing ties that `listBackupRecords` cannot resolve // (the sort has no tiebreaker). Bumping ensures strict ordering so // callers always see the true newest record first. let lastCreatedAt = 0; return { isEncryptionAvailable() { return isSafeStorageAvailable(safeStorage); }, async createBackup(options = {}) { const next = createBackupLock.then(() => doCreateBackup(options)); // Swallow the rejection on the lock chain so one caller's error // does not poison subsequent calls; each individual await sees its // own rejection via the `next` return. createBackupLock = next.catch(() => undefined); return next; }, async listBackups() { const records = await listBackupRecords(getBackupDir()); return records.map(({ record }) => toBackupSummary(record)); }, async readBackup(options = {}) { const backupId = typeof options.id === "string" ? options.id : ""; if (!backupId) { throw new Error("Missing vault backup id."); } const records = await listBackupRecords(getBackupDir()); const match = records.find(({ record }) => record.id === backupId); if (!match) { throw new Error("Vault backup not found."); } return { backup: toBackupSummary(match.record), payload: decodePayload(match.record, safeStorage), }; }, async trimBackups(options = {}) { return pruneBackupRecords(getBackupDir(), options.maxCount); }, async openBackupDir() { const dirPath = getBackupDir(); await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 }); if (shell?.openPath) { const errorMessage = await shell.openPath(dirPath); if (errorMessage) { throw new Error(errorMessage); } } return { success: true, path: dirPath, }; }, }; async function doCreateBackup(options) { const payload = options.payload; if (!payload || typeof payload !== "object" || Array.isArray(payload)) { throw new Error("Missing vault backup payload."); } // Refuse early when the payload is too large to prevent a // misbehaving or compromised renderer from filling the disk. The // check runs before any side effect so callers see a deterministic // failure rather than a partial write. const estimatedSize = estimatePayloadSize(payload); if (estimatedSize > MAX_PAYLOAD_BYTES) { throw new VaultBackupTooLargeError(estimatedSize); } // Refuse before doing anything side-effectful so callers get a clear // error rather than a silently-weakened plaintext backup. if (!isSafeStorageAvailable(safeStorage)) { throw new VaultBackupEncryptionUnavailableError(); } const dirPath = getBackupDir(); const existingRecords = await listBackupRecords(dirPath); const fingerprint = computePayloadFingerprint(payload); const latest = existingRecords[0]?.record ?? null; if (latest?.fingerprint === fingerprint) { return { created: false, backup: toBackupSummary(latest), }; } let createdAt = Date.now(); if (createdAt <= lastCreatedAt) createdAt = lastCreatedAt + 1; lastCreatedAt = createdAt; const id = crypto.randomUUID(); const preview = buildPreview(payload); const encoded = encodePayload(payload, safeStorage); const record = { formatVersion: 1, id, createdAt, reason: sanitizeReason(options.reason), syncDataVersion: sanitizeOptionalSyncDataVersion(options.syncDataVersion), sourceAppVersion: sanitizeOptionalVersionString(options.sourceAppVersion), targetAppVersion: sanitizeOptionalVersionString(options.targetAppVersion), fingerprint, preview, payloadEncoding: encoded.encoding, payloadData: encoded.data, }; const filePath = path.join( dirPath, `${BACKUP_FILE_PREFIX}${createdAt}-${id}${BACKUP_FILE_EXT}`, ); // Durable atomic write: serialize to a sibling tmp file, fsync the // file's data+metadata to stable storage, rename into place, then // fsync the directory entry itself. Without the file fsync a system // crash between writeFile and rename can leave the OS with a // successfully-renamed entry whose data blocks are still only in // page cache — the file is visible but reads back as zeros or torn // content. Without the directory fsync the rename itself may not be // durable: on recovery listBackups sees an empty directory even // though the file's blocks made it to disk. Both matter for the // protective-before-restore case, where the user is about to // overwrite their vault and the safety net MUST survive a crash // between backup and restore. const tmpPath = `${filePath}.tmp-${crypto.randomUUID()}`; let tmpHandle; try { tmpHandle = await fs.promises.open(tmpPath, 'w', 0o600); await tmpHandle.writeFile(`${JSON.stringify(record, null, 2)}\n`); await tmpHandle.sync(); } finally { if (tmpHandle) { try { await tmpHandle.close(); } catch { /* ignore — close failure after successful sync still leaves data durable on disk */ } } } try { await fs.promises.rename(tmpPath, filePath); } catch (renameError) { // Best-effort cleanup; swallow unlink errors so the rename error // surfaces to the caller. try { await fs.promises.unlink(tmpPath); } catch { /* ignore */ } throw renameError; } // fsync the directory so the rename itself is durably recorded. // On Linux this is required; on macOS it is a no-op at the FS // layer but still safe and portable. On Windows fs.open on a // directory is not supported — the rename is durable as part of // NTFS's journal, so skip the sync there. if (process.platform !== 'win32') { let dirHandle; try { dirHandle = await fs.promises.open(dirPath, 'r'); await dirHandle.sync(); } catch (dirSyncError) { // Directory fsync is a defense-in-depth hardening step — if // the filesystem refuses (tmpfs, some network mounts) the // rename already happened and the file is reachable, so a // failure here should not abort the backup. Log so a // systematic issue is diagnosable. console.warn('[vaultBackupBridge] Directory fsync failed:', dirSyncError); } finally { if (dirHandle) { try { await dirHandle.close(); } catch { /* ignore */ } } } } // Reuse the enumeration we already did for dedupe, prepending the // newly-written record so pruneBackupRecords can trim without // re-scanning the directory. Records are ordered newest-first. const nextRecords = [{ record, filePath }, ...existingRecords]; await pruneBackupRecords(dirPath, options.maxCount, nextRecords); return { created: true, backup: toBackupSummary(record), }; } } function registerHandlers(ipcMain, electronModule) { const service = createVaultBackupService({ app: electronModule?.app, safeStorage: electronModule?.safeStorage, shell: electronModule?.shell, }); const BrowserWindow = electronModule?.BrowserWindow; // Broadcast a backup-changed event to every renderer so other windows // (notably the Settings window's backup list) can refresh without the // user manually navigating. Any successful create / trim path calls // this. Failures fall through silently — a dropped notification is // recoverable on the next manual refresh, while re-throwing here // would turn a harmless broadcast failure into a user-visible error. const broadcastBackupsChanged = () => { if (!BrowserWindow?.getAllWindows) return; try { for (const win of BrowserWindow.getAllWindows()) { if (win.isDestroyed?.()) continue; try { win.webContents?.send?.("netcatty:vaultBackups:changed"); } catch (error) { console.warn("[vaultBackupBridge] Failed to notify window:", error); } } } catch (error) { console.warn("[vaultBackupBridge] Broadcast failed:", error); } }; ipcMain.handle("netcatty:vaultBackups:capabilities", async () => { return { encryptionAvailable: service.isEncryptionAvailable() }; }); ipcMain.handle("netcatty:vaultBackups:create", async (_event, payload) => { const result = await service.createBackup(payload || {}); // Only broadcast when a new record was actually written; a // deduped (created=false) return means the on-disk state did not // change, so other windows already show the latest backup. if (result?.created) { broadcastBackupsChanged(); } return result; }); ipcMain.handle("netcatty:vaultBackups:list", async () => { return service.listBackups(); }); ipcMain.handle("netcatty:vaultBackups:read", async (_event, payload) => { return service.readBackup(payload || {}); }); ipcMain.handle("netcatty:vaultBackups:trim", async (_event, payload) => { const result = await service.trimBackups(payload || {}); if (result?.deletedCount) { broadcastBackupsChanged(); } return result; }); ipcMain.handle("netcatty:vaultBackups:openDir", async () => { return service.openBackupDir(); }); } module.exports = { BACKUP_DIR_NAME, BACKUP_FILE_EXT, BACKUP_FILE_PREFIX, MAX_PAYLOAD_BYTES, VaultBackupEncryptionUnavailableError, VaultBackupTooLargeError, buildPreview, computePayloadFingerprint, createVaultBackupService, registerHandlers, };