Files
Shade/packages/shade-recovery/src/setup.ts

230 lines
8.5 KiB
TypeScript
Raw Normal View History

/**
* 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:<encoded>`).
* 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<void>;
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<string>;
/**
* 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<string>;
/**
* 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<GuardianDelivery>;
/** 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<SetupRecoveryResult> {
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');
}
}