import { createHash, randomBytes } from "node:crypto"; import { createReadStream, createWriteStream } from "node:fs"; import { chmod, lstat, mkdir, mkdtemp, rm, statfs } from "node:fs/promises"; import { isAbsolute, join, parse, resolve } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; const MAX_BYTES = 10 * 1024 ** 3; const PART_BYTES = 64 * 1024 ** 2; const SPACE_MARGIN = 64 * 1024 ** 2; const MAGIC = Buffer.from("OMB-WORKSPACE-1\n"); const HEADER_BYTES = MAGIC.length + 16 + 12; const MIN_BYTES = HEADER_BYTES + 16; const UUID = /^[a-f\d]{8}-[a-f\d]{4}-4[a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/; const HASH = /^[a-f\d]{64}$/; const ETAG = /^(?:"[a-f\d]{32}"|[a-f\d]{32})$/; const R2_HOST = /^(?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.)*[a-f\d]{32}(?:\.(?:eu|us|fedramp))?\.r2\.cloudflarestorage\.com$/; const LOCAL = "/api/workspace-backup"; const CLOUD = "/api/desktop/backups"; export class CompanyBackupError extends Error { constructor(code, message) { super(message); this.name = "CompanyBackupError"; this.code = code; } } const fail = (code, message) => { throw new CompanyBackupError(code, message); }; const record = value => value !== null && typeof value === "object" && !Array.isArray(value); const archiveSize = value => Number.isSafeInteger(value) && value >= MIN_BYTES && value <= MAX_BYTES; const identifier = value => typeof value === "string" && UUID.test(value); const checkPassword = password => { if (typeof password !== "string" || password.length < 12 || password.length > 1024) fail("invalid_password", "Use a backup password between 12 and 1,024 characters."); }; const safeError = (error, signal) => { if (error instanceof CompanyBackupError) return error; if (signal?.aborted || error?.name === "AbortError") return new CompanyBackupError("cancelled", "The backup transfer was cancelled or timed out."); if (error?.code === "ENOSPC") return new CompanyBackupError("no_space", "There is not enough free disk space for this backup transfer."); if (error?.status === 401 || error?.status === 403) return new CompanyBackupError("access_changed", "Company access changed. Reconnect your desktop and try again."); // Fetch/provider exceptions may contain signed URLs. Never forward them. return new CompanyBackupError("transfer_failed", "The backup transfer could not be completed. Please try again."); }; async function discard(response) { try { await response?.body?.cancel(); } catch {} } // Node opens a read stream lazily; an open that fails after the temp dir is removed must not be uncaught, since the request outcome decides. const bodyStream = (file, range) => createReadStream(file, { ...range, highWaterMark: 256 * 1024 }).on("error", () => {}); function signedUrl(value, allowLoopbackForTests) { let url; try { url = new URL(value); } catch { fail("invalid_response", "The backup service returned an invalid storage address."); } const loopback = allowLoopbackForTests && ["127.0.0.1", "[::1]", "localhost"].includes(url.hostname) && ["http:", "https:"].includes(url.protocol); if (url.username || url.password || url.hash || (!loopback && (url.protocol !== "https:" || url.port || !R2_HOST.test(url.hostname)))) { fail("invalid_response", "The backup service returned an unsupported storage address."); } return url.href; } function cloudMetadata(value, expectedStatus, expected) { if (!record(value) || !identifier(value.id) || value.status !== expectedStatus || !archiveSize(value.sizeBytes) || typeof value.sha256 !== "string" || !HASH.test(value.sha256) || (expected && (value.id !== expected.id || value.sizeBytes !== expected.sizeBytes || value.sha256 !== expected.sha256))) { fail("invalid_response", "The backup service returned inconsistent archive metadata."); } return { id: value.id, status: value.status, sizeBytes: value.sizeBytes, sha256: value.sha256, passwordRequired: value.passwordRequired !== false, ...(typeof value.appVersion === "string" ? { appVersion: value.appVersion.slice(0, 64) } : {}), ...(Number.isSafeInteger(value.createdAt) ? { createdAt: value.createdAt } : {}), ...(Number.isSafeInteger(value.completedAt) ? { completedAt: value.completedAt } : {}), }; } function liveExpiry(value) { if (!Number.isSafeInteger(value) || value <= Date.now() || value > Date.now() + 15 * 60_000) fail("invalid_response", "The storage link has expired or has an invalid lifetime. Try again."); } /** Transfer existing encrypted .ombbackup archives. Never commits a restore. */ export function createCompanyBackups({ localRequest, portalRequest, tempRoot, fetchImpl = fetch, allowLoopbackForTests = false, availableBytes }) { if (typeof localRequest !== "function" || typeof portalRequest !== "function" || typeof fetchImpl !== "function" || typeof tempRoot !== "string" || !isAbsolute(tempRoot) || resolve(tempRoot) === parse(resolve(tempRoot)).root) throw new Error("A private backup transfer directory and request adapters are required."); const freeBytes = availableBytes ?? (async path => { const disk = await statfs(path, { bigint: true }); return Number(disk.bavail * disk.bsize > BigInt(Number.MAX_SAFE_INTEGER) ? BigInt(Number.MAX_SAFE_INTEGER) : disk.bavail * disk.bsize); }); let operating = false; async function checkSpace(path, required) { const available = await freeBytes(path); if (!Number.isFinite(available) || available < required + SPACE_MARGIN) fail("no_space", "There is not enough free disk space for this backup transfer and its temporary files."); } async function localJson(path, body, signal, preview = false) { const response = await localRequest(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal, redirect: "error" }); if (!response.ok) { await discard(response); if (response.status === 401 || response.status === 403) fail("access_changed", "Access to this installation changed. Reopen Backups and try again."); if (preview && response.status === 400) fail("invalid_archive", "The backup password is incorrect, or the archive is damaged or unsupported. Nothing was restored."); if ([409, 503].includes(response.status)) fail("workspace_busy", "Wait for this installation to finish its current work before backing up or restoring."); fail("local_backup_failed", "The local backup operation could not be completed. Check Backups in the desktop app and try again."); } if (!response.body) fail("invalid_response", "The local backup service returned an empty response."); const chunks = []; let bytes = 0; for await (const chunk of Readable.fromWeb(response.body)) { signal.throwIfAborted(); bytes += chunk.length; if (bytes > 2 * 1024 ** 2) fail("invalid_response", "The local backup response was too large."); chunks.push(chunk); } let result; try { result = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { fail("invalid_response", "The local backup service returned invalid metadata."); } if (!record(result)) fail("invalid_response", "The local backup service returned invalid metadata."); return result; } async function transferToFile(response, file, expectedBytes, signal, report, phase) { const declared = response.headers.get("content-length"); if (!response.ok || response.status !== 200 || !response.body || (declared !== null && (!/^\d+$/.test(declared) || Number(declared) !== expectedBytes)) || (response.headers.has("content-encoding") && response.headers.get("content-encoding") !== "identity")) { await discard(response); fail("invalid_archive", "The archive download has an invalid size or encoding."); } const hash = createHash("sha256"), prefix = Buffer.alloc(HEADER_BYTES); let bytes = 0, checkedAt = 0; await pipeline(Readable.fromWeb(response.body), async function* (source) { for await (const chunk of source) { signal.throwIfAborted(); if (bytes + chunk.length > expectedBytes) fail("invalid_archive", "The archive download exceeded its declared size."); if (bytes < HEADER_BYTES) chunk.copy(prefix, bytes, 0, Math.min(chunk.length, HEADER_BYTES - bytes)); bytes += chunk.length; hash.update(chunk); if (bytes - checkedAt >= PART_BYTES) { checkedAt = bytes; await checkSpace(file, expectedBytes - bytes); } report(phase, bytes, expectedBytes); yield chunk; } }, createWriteStream(file, { flags: "wx", mode: 0o600 }), { signal }); if (bytes !== expectedBytes) fail("invalid_archive", "The archive download was incomplete."); if (!prefix.subarray(0, MAGIC.length).equals(MAGIC)) fail("invalid_archive", "This is not a supported encrypted installation backup."); return hash.digest("hex"); } async function storageRequest(url, init) { const response = await fetchImpl(url, { ...init, redirect: "error", credentials: "omit", referrerPolicy: "no-referrer" }); if (response.redirected || (response.url && response.url !== url)) { await discard(response); fail("invalid_response", "The backup storage request was redirected and was stopped."); } return response; } async function run(signal, onProgress, work) { if (operating) fail("busy", "Another cloud backup transfer is in progress."); operating = true; const operationSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(24 * 3600_000)]) : AbortSignal.timeout(24 * 3600_000); let directory, result, failure, lastPhase = "", lastAt = 0; const report = (phase, bytesTransferred = 0, totalBytes = 0) => { if (phase === lastPhase && bytesTransferred !== totalBytes && Date.now() - lastAt < 250) return; lastPhase = phase; lastAt = Date.now(); try { onProgress?.({ phase, bytesTransferred, totalBytes }); } catch {} }; try { operationSignal.throwIfAborted(); await mkdir(tempRoot, { recursive: true, mode: 0o700 }); const root = await lstat(tempRoot); if (!root.isDirectory() || root.isSymbolicLink() || (process.platform !== "win32" && (root.mode & 0o077) !== 0)) fail("unsafe_directory", "Backup transfers require a private temporary directory."); directory = await mkdtemp(join(tempRoot, "company-backup-")); await chmod(directory, 0o700); await checkSpace(directory, 0); result = await work(directory, operationSignal, report); } catch (error) { failure = safeError(error, operationSignal); } finally { try { if (directory) await rm(directory, { recursive: true, force: true }); } catch { failure = new CompanyBackupError("cleanup_failed", "The encrypted temporary transfer file could not be removed. Close the app and check its backup cache."); } finally { operating = false; } } if (failure) throw failure; return result; } return { backup(input, signal, onProgress) { return run(signal, onProgress, async (directory, operationSignal, report) => { const password = randomBytes(32).toString("base64url"); const clientState = input?.clientState ?? {}; if (!record(clientState) || Object.values(clientState).some(value => typeof value !== "string") || Buffer.byteLength(JSON.stringify(clientState)) > 2 * 1024 ** 2) fail("invalid_preferences", "Saved desktop preferences exceed the supported backup limit."); report("exporting"); const exported = await localJson(`${LOCAL}/export`, { password, clientState }, operationSignal); if (!identifier(exported.id) || !archiveSize(exported.bytes)) fail("invalid_archive", "The exported encrypted archive must contain no more than 10 GiB."); const appVersion = input.appVersion ?? exported.summary?.appVersion ?? "unknown"; if (typeof appVersion !== "string" || !/^[a-zA-Z\d][a-zA-Z\d ._+()-]{0,63}$/.test(appVersion)) fail("invalid_response", "The desktop app version is invalid."); await checkSpace(directory, exported.bytes); const response = await localRequest(`${LOCAL}/download/${exported.id}`, { method: "GET", signal: operationSignal, redirect: "error" }); const file = join(directory, "workspace.ombbackup"); report("reading", 0, exported.bytes); const sha256 = await transferToFile(response, file, exported.bytes, operationSignal, report, "reading"); let pendingId = null; try { operationSignal.throwIfAborted(); const started = await portalRequest(CLOUD, { method: "POST", body: { sizeBytes: exported.bytes, sha256, appVersion, unlockKey: password }, signal: operationSignal }); if (identifier(started?.id)) pendingId = started.id; const backup = cloudMetadata(started, "uploading"); if (backup.passwordRequired) fail("update_required", "Update your organization's Admin service to enable passwordless backups."); if (backup.sizeBytes !== exported.bytes || backup.sha256 !== sha256 || started.partSizeBytes !== PART_BYTES || started.partCount !== Math.ceil(exported.bytes / PART_BYTES)) fail("invalid_response", "The backup service returned an invalid multipart plan."); const parts = []; report("uploading", 0, exported.bytes); for (let partNumber = 1; partNumber <= started.partCount; partNumber++) { operationSignal.throwIfAborted(); const start = (partNumber - 1) * PART_BYTES, sizeBytes = Math.min(PART_BYTES, exported.bytes - start); const signed = await portalRequest(`${CLOUD}/${backup.id}/parts/${partNumber}`, { method: "POST", body: {}, signal: operationSignal }); if (!record(signed) || signed.partNumber !== partNumber || signed.sizeBytes !== sizeBytes || !record(signed.headers) || Object.keys(signed.headers).some(name => name.toLowerCase() !== "content-length") || new Headers(signed.headers).get("content-length") !== String(sizeBytes)) fail("invalid_response", "The backup service returned invalid upload instructions."); liveExpiry(signed.expiresAt); const url = signedUrl(signed.url, allowLoopbackForTests), stream = bodyStream(file, { start, end: start + sizeBytes - 1 }); let uploaded; try { uploaded = await storageRequest(url, { method: "PUT", headers: { "content-length": String(sizeBytes) }, body: stream, duplex: "half", signal: operationSignal }); } finally { stream.destroy(); } const etag = uploaded.headers.get("etag"); const accepted = uploaded.ok; await discard(uploaded); if (!accepted || !etag || !ETAG.test(etag)) fail("upload_failed", "A backup part could not be uploaded and verified. Try the backup again."); parts.push({ partNumber, etag }); report("uploading", start + sizeBytes, exported.bytes); } report("completing", exported.bytes, exported.bytes); const completed = await portalRequest(`${CLOUD}/${backup.id}/complete`, { method: "POST", body: { parts }, signal: operationSignal }); const result = cloudMetadata(completed, "ready", backup); if (result.passwordRequired) fail("invalid_response", "The backup service did not retain the encryption key."); pendingId = null; report("ready", exported.bytes, exported.bytes); return result; } finally { if (pendingId) { try { await portalRequest(`${CLOUD}/${pendingId}/abort`, { method: "POST", body: {}, signal: AbortSignal.timeout(10_000) }); } catch {} } } }); }, prepareRestore(input, signal, onProgress) { return run(signal, onProgress, async (directory, operationSignal, report) => { if (!identifier(input?.id)) fail("invalid_archive", "Select a valid cloud backup."); const signed = await portalRequest(`${CLOUD}/${input.id}/download`, { method: "POST", body: {}, signal: operationSignal }); const backup = cloudMetadata(signed, "ready"); // Keys stay in the native process, never in the renderer or object URL. const password = backup.passwordRequired ? input.password : signed.unlockKey; checkPassword(password); if (backup.id !== input.id) fail("invalid_response", "The backup service returned a different archive."); liveExpiry(signed.expiresAt); const url = signedUrl(signed.url, allowLoopbackForTests); // Version 1 is uncompressed tar: transfer, upload, decrypted tar and staging can coexist during preview. await checkSpace(directory, backup.sizeBytes * 4); report("downloading", 0, backup.sizeBytes); const downloaded = await storageRequest(url, { method: "GET", signal: operationSignal }); const file = join(directory, "workspace.ombbackup"); const sha256 = await transferToFile(downloaded, file, backup.sizeBytes, operationSignal, report, "downloading"); report("validating", backup.sizeBytes, backup.sizeBytes); if (sha256 !== backup.sha256) fail("checksum_mismatch", "The downloaded backup checksum did not match. Nothing was uploaded locally or restored."); await checkSpace(directory, backup.sizeBytes * 3); operationSignal.throwIfAborted(); report("preparing", 0, backup.sizeBytes); const stream = bodyStream(file); let response; try { response = await localRequest(`${LOCAL}/upload`, { method: "POST", headers: { "content-type": "application/octet-stream", "content-length": String(backup.sizeBytes) }, body: stream, duplex: "half", signal: operationSignal, redirect: "error" }); } finally { stream.destroy(); } if (!response.ok) { await discard(response); fail("local_backup_failed", "The verified archive could not be staged in this installation."); } const receipt = await readUploadReceipt(response, operationSignal); if (!identifier(receipt.id)) fail("invalid_response", "This installation returned an invalid upload receipt."); await checkSpace(directory, backup.sizeBytes * 2); operationSignal.throwIfAborted(); const preview = await localJson(`${LOCAL}/preview`, { id: receipt.id, password }, operationSignal, true); if (!identifier(preview.id) || !record(preview.summary) || preview.summary.format !== "openmaus.workspace-backup" || preview.summary.version !== 1 || !identifier(preview.summary.id) || !Number.isSafeInteger(preview.summary.bytes) || preview.summary.bytes < 0 || preview.summary.bytes > MAX_BYTES) fail("invalid_response", "This installation returned an invalid restore preview."); report("ready", backup.sizeBytes, backup.sizeBytes); return { id: preview.id, summary: preview.summary }; }); }, }; } async function readUploadReceipt(response, signal) { if (!response.body) fail("invalid_response", "This installation returned an empty upload receipt."); const chunks = []; let bytes = 0; for await (const chunk of Readable.fromWeb(response.body)) { signal.throwIfAborted(); bytes += chunk.length; if (bytes > 4096) fail("invalid_response", "The local upload receipt was too large."); chunks.push(chunk); } try { const value = JSON.parse(Buffer.concat(chunks).toString("utf8")); if (record(value)) return value; } catch {} fail("invalid_response", "This installation returned an invalid upload receipt."); }