~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
266 lines · 8.6 KB Raw History
// @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');
  }
}