chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @pdsjs/core/backup - Backup state shapes and key-backup encryption
//
// A backup is a snapshot in a BackupTargetPort: `snapshots/<stamp>/repo.car`
// (the full signed repo, same bytes as com.atproto.sync.getRepo) plus a
// manifest, with blobs deduplicated across snapshots at `blobs/<cid>`.
// 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/<stamp>`
* @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<string>}
*/
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<CryptoKey>}
*/
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<CryptoKey>}
*/
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<EncryptedKeyBackup>}
*/
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<EncryptedKeyBackup>}
*/
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<Uint8Array>}
*/
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');
}
}