Files
NetMesh/electron/bridges/vaultBackupBridge.cjs

597 lines
22 KiB
JavaScript
Raw Normal View History

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,
};