// @pdsjs/core/backup - Backup state shapes and key-backup encryption // // A backup is a snapshot in a BackupTargetPort: `snapshots//repo.car` // (the full signed repo, same bytes as com.atproto.sync.getRepo) plus a // manifest, with blobs deduplicated across snapshots at `blobs/`. // Settings and run history live in actor storage as one JSON document, so no // adapter needs a new table for them. import { base64UrlDecode, base64UrlEncode, bytesToHex } from './crypto.js'; export const DEFAULT_BACKUP_INTERVAL_HOURS = 24; export const DEFAULT_BACKUP_RETAIN = 5; /** Finished runs kept in history; older ones fall off the end. */ export const BACKUP_RUNS_KEPT = 20; /** How often a run persists its progress (and proves it is alive). */ export const BACKUP_CHECKPOINT_MS = 10_000; /** * A 'running' run whose heartbeat is older than this is dead — the isolate * was killed mid-copy. Generous next to the checkpoint interval, so a slow * phase (a huge CAR build, a long target listing) is not mistaken for death. */ export const BACKUP_STALE_MS = 3 * 60_000; /** * Whether a run claims to be running but has stopped proving it. * @param {BackupRun} run * @param {number} now - Epoch ms * @returns {boolean} */ export function isStaleRun(run, now) { if (run.status !== 'running') return false; const beat = Date.parse(run.heartbeatAt || run.startedAt); return !Number.isFinite(beat) || now - beat > BACKUP_STALE_MS; } /** * OWASP's 2023 floor for PBKDF2-SHA-256. The derivation at this count runs * in the browser: Cloudflare Workers caps PBKDF2 at 100k iterations, so the * server only ever receives the derived key, never the passphrase. */ export const KEY_BACKUP_ITERATIONS = 600_000; /** * The account's signing key, sealed under a passphrase so a snapshot can * restore the account somewhere else without the target ever holding the key * in the clear. The passphrase itself is never stored. * * @typedef {Object} EncryptedKeyBackup * @property {1} v - Format version * @property {string} salt - base64url, 16 bytes, PBKDF2 salt * @property {string} iv - base64url, 12 bytes, AES-GCM nonce * @property {number} iterations - PBKDF2-SHA-256 iteration count * @property {string} ciphertext - base64url AES-256-GCM output * @property {string} keyHash - hex SHA-256 of the plaintext key bytes, to * detect that the account's key rotated after this copy was sealed * @property {string} createdAt - ISO 8601 datetime */ /** * @typedef {Object} BackupConfig * @property {boolean} enabled * @property {number} intervalHours * @property {number} retain - Snapshots kept; older ones are pruned * @property {EncryptedKeyBackup|null} keyBackup */ /** * @typedef {Object} BackupRun * @property {string} id - The snapshot stamp * @property {'manual'|'scheduled'} trigger * @property {'running'|'ok'|'error'} status * @property {string} startedAt - ISO 8601 datetime * @property {string|null} finishedAt - ISO 8601 datetime * @property {string} [heartbeatAt] - ISO 8601 datetime of the last progress * checkpoint; how a later observer tells a live run from a dead one * @property {number} blobsCopied - Blobs uploaded by this run * @property {number} blobsTotal - Blobs the account holds * @property {number} blobsFailed - Blobs this run could not copy. They are * named in the manifest and tried again by the next run, which is what a * snapshot missing a blob costs * @property {string} [blobError] - The first failure's message * @property {number} carBytes - Size of the repo CAR * @property {number} bytesUploaded - Everything this run wrote to the target * @property {string} snapshot - Target key prefix, `snapshots/` * @property {boolean} keyIncluded * @property {boolean} keyStale - Key backup present but sealed before the * current signing key existed * @property {string} [error] - Present when status is 'error' */ /** * @typedef {Object} BackupState * @property {BackupConfig} config * @property {BackupRun[]} runs - Newest first * @property {number|null} nextRunAt - Epoch ms; null while disabled */ /** @returns {BackupState} */ export function defaultBackupState() { return { config: { enabled: false, intervalHours: DEFAULT_BACKUP_INTERVAL_HOURS, retain: DEFAULT_BACKUP_RETAIN, keyBackup: null, }, runs: [], nextRunAt: null, }; } /** * A filename-safe, lexically sortable snapshot name from a date: * `2026-08-06T12-00-00.123Z`. Milliseconds included so two manual runs * cannot collide on a name. * @param {Date} date * @returns {string} */ export function snapshotStamp(date) { return date.toISOString().replaceAll(':', '-'); } /** * Hex SHA-256 of key bytes, stored beside the sealed copy so a later run can * tell that the account's signing key is no longer the one sealed here. * @param {Uint8Array} keyBytes * @returns {Promise} */ export async function keyFingerprint(keyBytes) { const digest = await crypto.subtle.digest( 'SHA-256', /** @type {BufferSource} */ (keyBytes), ); return bytesToHex(new Uint8Array(digest)); } /** * @param {string} passphrase * @returns {Promise} */ async function passphraseKey(passphrase) { return crypto.subtle.importKey( 'raw', /** @type {BufferSource} */ (new TextEncoder().encode(passphrase)), 'PBKDF2', false, ['deriveKey', 'deriveBits'], ); } /** * @param {CryptoKey} baseKey * @param {Uint8Array} salt * @param {number} iterations * @returns {Promise} */ async function deriveAesKey(baseKey, salt, iterations) { return crypto.subtle.deriveKey( { name: 'PBKDF2', hash: 'SHA-256', salt: /** @type {BufferSource} */ (salt), iterations, }, baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'], ); } /** * Seal the signing key under an already-derived AES key. This is the entry * the server uses: the browser runs PBKDF2 over the passphrase (Workers cap * the iteration count far below a safe one) and sends the derived key, salt * and count here; only salt and count are stored, so decryption still needs * the passphrase. * @param {{derivedKey: Uint8Array, salt: Uint8Array, iterations: number}} material * @param {Uint8Array} keyBytes - The stored key, as getPrivateKey returns it * @returns {Promise} */ export async function sealKeyBackup( { derivedKey, salt, iterations }, keyBytes, ) { const iv = crypto.getRandomValues(new Uint8Array(12)); const aesKey = await crypto.subtle.importKey( 'raw', /** @type {BufferSource} */ (derivedKey), { name: 'AES-GCM' }, false, ['encrypt'], ); const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, aesKey, /** @type {BufferSource} */ (keyBytes), ); return { v: 1, salt: base64UrlEncode(salt), iv: base64UrlEncode(iv), iterations, ciphertext: base64UrlEncode(new Uint8Array(ciphertext)), keyHash: await keyFingerprint(keyBytes), createdAt: new Date().toISOString(), }; } /** * Seal the signing key straight from a passphrase — derivation and sealing * in one place, for restore tooling and runtimes without the Workers * iteration cap. The server API path uses sealKeyBackup instead. * @param {string} passphrase * @param {Uint8Array} keyBytes - The stored key, as getPrivateKey returns it * @returns {Promise} */ export async function encryptKeyBackup(passphrase, keyBytes) { const salt = crypto.getRandomValues(new Uint8Array(16)); const baseKey = await passphraseKey(passphrase); const bits = await crypto.subtle.deriveBits( { name: 'PBKDF2', hash: 'SHA-256', salt: /** @type {BufferSource} */ (salt), iterations: KEY_BACKUP_ITERATIONS, }, baseKey, 256, ); return sealKeyBackup( { derivedKey: new Uint8Array(bits), salt, iterations: KEY_BACKUP_ITERATIONS, }, keyBytes, ); } /** * Open a sealed key backup. Used at restore time (and by tests); the server * itself never needs to. * @param {string} passphrase * @param {EncryptedKeyBackup} backup * @returns {Promise} */ export async function decryptKeyBackup(passphrase, backup) { const aesKey = await deriveAesKey( await passphraseKey(passphrase), base64UrlDecode(backup.salt), backup.iterations, ); try { const plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: /** @type {BufferSource} */ (base64UrlDecode(backup.iv)), }, aesKey, /** @type {BufferSource} */ (base64UrlDecode(backup.ciphertext)), ); return new Uint8Array(plaintext); } catch { throw new Error('Wrong passphrase, or the key backup is corrupt'); } }