/** * Primary-device flow: distribute Shamir shares of an identity backup * to a quorum of guardians. * * The setup happens entirely over existing 1:1 Shade sessions — no * server-side coordination, no separate transport. The primary calls * `setupRecovery({ shade, guardians, threshold, deliver })` once; * under the hood we: * * 1. Generate a 32-byte uniformly-random `recoveryKey`. * 2. Encode it as a base64url passphrase (`shade-rk:`). * 3. Call `shade.exportBackup(passphrase, knownAddresses)` — that * gives us an AES-GCM-protected backup blob whose key is derived * from the recoveryKey via HKDF inside the SDK helper. * 4. Shamir-split the recoveryKey into `guardians.length` shares * with threshold `threshold`. * 5. For each guardian, call `shade.send(...)` to encrypt a * `share-deposit` envelope, then hand it to the caller-supplied * `deliver` callback for transport. Failures on individual * guardians do NOT abort the loop — partial-distribution is * reported back so the caller can surface it. * 6. Zero the recoveryKey and the in-memory share buffers. */ import type { ShadeEnvelope } from '@shade/core'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import type { Shade } from '@shade/sdk'; import { bytesToBase64Url, recoveryKeyToBackupPassphrase, } from './encoding.js'; import { encodeRecoveryEnvelope, type ShareDepositEnvelope } from './protocol.js'; import { encodeShare, splitSecret } from './shamir.js'; /** * Caller-supplied envelope-delivery callback. The recovery package is * transport-agnostic by design (Shade itself is); whatever channel * the host application uses for plaintext messages — WebSocket, push * notification, polling, in-process pipe in tests — the caller * implements `deliver` to put the encrypted envelope on it. The * receiving side is expected to call `shade.receive(from, envelope)` * which will fire the `onMessage` handler that `attachGuardian` / * `requestRecovery` register. */ export type RecoveryDeliver = (to: string, envelope: ShadeEnvelope) => Promise; export interface SetupRecoveryOptions { /** Initialized Shade instance whose identity will be backed up. */ shade: Shade; /** * Guardian addresses, in stable order. The order determines each * guardian's Shamir x-coordinate (1..n), so callers should keep the * list stable across re-runs of setup if they want predictable share * indices. Length must equal `n` and be in [1, 255]. */ guardians: ReadonlyArray; /** * Reconstruction threshold `k`. Any subset of `k` guardians can * recover the identity; any subset of `k-1` reveals nothing. Must * satisfy `1 ≤ threshold ≤ guardians.length`. */ threshold: number; /** Outbound transport — see {@link RecoveryDeliver}. */ deliver: RecoveryDeliver; /** * Peer addresses whose Double-Ratchet sessions should be included in * the backup. Forwarded verbatim to `Shade.exportBackup`. Pass `[]` * (default) to back up identity + prekeys only. */ knownAddresses?: ReadonlyArray; /** * Optional caller-supplied setupId. Defaults to a fresh random id. * Useful when re-running setup to refresh share material in-place * without rotating the setupId (e.g. after replacing one guardian). */ setupId?: string; /** * Wall-clock timestamp embedded in each share-deposit envelope. * Defaults to `Date.now()`. Tests inject a fixed value for * determinism. */ now?: () => number; } export interface SetupRecoveryResult { /** Stable id the user records alongside their guardian roster. */ setupId: string; /** k (threshold) and n (guardians.length), echoed for caller convenience. */ threshold: number; guardianCount: number; /** Per-guardian outcome of the share-deposit send. */ deliveries: ReadonlyArray; /** True iff every guardian got the deposit — convenience flag. */ allDelivered: boolean; /** Original device's safety number at setup time, for the user's records. */ setupFingerprint: string; } export interface GuardianDelivery { guardianAddress: string; shareIndex: number; /** * `null` when the deposit was sent successfully. Otherwise the * `Error` raised by `Shade.send` or the `deliver` callback. Failed * deliveries DO NOT abort the remaining sends — partial-distribution * is recoverable by retrying just the failures or removing the * unreachable guardian and re-running setup. */ error: Error | null; } /** * Distribute a fresh Shamir-split recovery setup to the supplied * guardians. See module-level docs for the full flow. Idempotent only * insofar as the caller pins `setupId` across re-runs; without a * pinned id every call generates a new (recoveryKey, setupId) pair so * old shares are silently superseded. */ export async function setupRecovery(opts: SetupRecoveryOptions): Promise { validateOptions(opts); const crypto = new SubtleCryptoProvider(); const guardianCount = opts.guardians.length; const threshold = opts.threshold; const setupId = opts.setupId ?? bytesToBase64Url(crypto.randomBytes(16)); const now = opts.now ?? Date.now; // 1. Random recoveryKey + 2/3. Backup blob keyed by recoveryKey-derived passphrase. const recoveryKey = crypto.randomBytes(32); let setupFingerprint: string; let backupBlob: string; try { const passphrase = recoveryKeyToBackupPassphrase(recoveryKey); setupFingerprint = await opts.shade.fingerprint; backupBlob = await opts.shade.exportBackup(passphrase, [...(opts.knownAddresses ?? [])]); } catch (err) { crypto.zeroize(recoveryKey); throw err; } // 4. Shamir-split the recoveryKey. let shares; try { shares = splitSecret( recoveryKey, threshold, guardianCount, (length) => crypto.randomBytes(length), ); } finally { // The recoveryKey lives in `splitSecret` only as the constant term // of each per-byte polynomial; once split returns we can wipe it. crypto.zeroize(recoveryKey); } // 5. Deliver one share-deposit per guardian. const flowId = `setup:${setupId}`; const createdAt = now(); const deliveries: GuardianDelivery[] = []; for (let i = 0; i < guardianCount; i++) { const guardianAddress = opts.guardians[i]!; const share = shares[i]!; const shareBytes = bytesToBase64Url(encodeShare(share)); const envelope: ShareDepositEnvelope = { shadeRecovery: 1, type: 'share-deposit', flowId, originalAddress: opts.shade.myAddress, setupId, threshold, guardianCount, shareIndex: share.x, shareBytes, backupBlob, setupFingerprint, createdAt, }; const plaintext = encodeRecoveryEnvelope(envelope); let error: Error | null = null; try { const env = await opts.shade.send(guardianAddress, plaintext); await opts.deliver(guardianAddress, env); } catch (err) { error = err instanceof Error ? err : new Error(String(err)); } deliveries.push({ guardianAddress, shareIndex: share.x, error }); // Wipe the share's y-buffer once it has been encoded + sent so it // doesn't linger in JS memory longer than necessary. share.y.fill(0); } return { setupId, threshold, guardianCount, deliveries, allDelivered: deliveries.every((d) => d.error === null), setupFingerprint, }; } function validateOptions(opts: SetupRecoveryOptions): void { if (typeof opts.threshold !== 'number' || !Number.isInteger(opts.threshold)) { throw new TypeError('setupRecovery: threshold must be an integer'); } if (opts.threshold < 1) { throw new RangeError('setupRecovery: threshold must be ≥ 1'); } if (!Array.isArray(opts.guardians) || opts.guardians.length === 0) { throw new RangeError('setupRecovery: guardians must be a non-empty array'); } if (opts.guardians.length > 255) { throw new RangeError('setupRecovery: guardians.length must be ≤ 255 (GF(2^8))'); } if (opts.threshold > opts.guardians.length) { throw new RangeError('setupRecovery: threshold must be ≤ guardians.length'); } if (new Set(opts.guardians).size !== opts.guardians.length) { throw new RangeError('setupRecovery: guardians must be unique'); } if (opts.guardians.includes(opts.shade.myAddress)) { throw new RangeError( 'setupRecovery: cannot use your own address as a guardian (the share would die with the device)', ); } if (typeof opts.deliver !== 'function') { throw new TypeError('setupRecovery: deliver must be a function'); } }