230 lines
8.5 KiB
TypeScript
230 lines
8.5 KiB
TypeScript
|
|
/**
|
||
|
|
* 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');
|
||
|
|
}
|
||
|
|
}
|