Files
Shade/packages/shade-recovery/src/request.ts
Sterister e6fdf31b49
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00

388 lines
14 KiB
TypeScript

/**
* New-device flow: rebuild the original identity from threshold-many
* guardian shares.
*
* Sequence:
*
* 1. The new device boots a Shade with a temporary identity (caller
* must do this BEFORE invoking `requestRecovery`; the new device
* needs a published prekey bundle so guardians can reply).
* 2. The new device's safety number is read off `shade.fingerprint`
* and embedded in every `recovery-request` envelope so the
* guardian's user can OOB-confirm before approving.
* 3. For each guardian in the supplied list, we send one
* `recovery-request` envelope. We register a transient
* `Shade.onMessage` handler that collects the matching
* `share-grant` and `share-decline` replies.
* 4. When `threshold` distinct grants have arrived, we Shamir-combine
* them, re-derive the backup passphrase, and call
* `Shade.importBackup` — which atomically swaps the temporary
* identity for the recovered one.
* 5. If too many guardians decline (so the threshold can no longer
* be reached) or the timeout elapses, we abort with a typed
* error.
*
* The reconstruction is authenticated end-to-end: a forged share is
* detected when the AES-GCM tag inside the backup blob fails to
* verify. We retry combination with subsets of size `threshold` from
* the received-grants pool until one succeeds OR every subset fails;
* the latter is a {@link RecoveryReconstructionError} the caller
* MUST treat as adversarial (do not retry blindly — at least one
* guardian is malicious).
*/
import type { ShadeEnvelope } from '@shade/core';
import type { Shade } from '@shade/sdk';
import {
base64UrlToBytes,
bytesToBase64Url,
recoveryKeyToBackupPassphrase,
} from './encoding.js';
import {
RecoveryDeclinedError,
RecoveryReconstructionError,
RecoveryTimeoutError,
} from './errors.js';
import {
encodeRecoveryEnvelope,
tryParseRecoveryEnvelope,
type RecoveryRequestEnvelope,
} from './protocol.js';
import type { RecoveryDeliver } from './setup.js';
import { combineShares, decodeShare, type ShamirShare } from './shamir.js';
export interface RequestRecoveryOptions {
/** Initialized Shade with a temporary identity. */
shade: Shade;
/** Address of the original (lost) identity to recover. */
originalAddress: string;
/**
* Guardians to query, in any order. Caller can supply a superset of
* the threshold — we'll continue collecting until we have enough
* grants, then stop early. Length must be ≥ threshold.
*/
guardians: ReadonlyArray<string>;
/** Reconstruction threshold `k`. Must match the value used at setup time. */
threshold: number;
/** Setup id from the original setupRecovery() call. */
setupId: string;
/** Outbound transport callback, same shape as setupRecovery. */
deliver: RecoveryDeliver;
/**
* Timeout for the entire flow in milliseconds. Defaults to 5 min.
* Pass `Infinity` to disable.
*/
timeoutMs?: number;
/**
* Optional progress callback. Fired once per guardian response. The
* caller wires this to UI so the user sees "3/5 guardians responded".
*/
onProgress?: (progress: RecoveryProgress) => void;
/** Wall-clock source. Defaults to Date.now. */
now?: () => number;
}
export interface RecoveryProgress {
granted: number;
declined: number;
pending: number;
threshold: number;
/** Latest guardian to respond. */
fromAddress?: string;
/** Outcome of the latest response. */
latest?: 'grant' | 'decline';
}
export interface RecoveryResult {
/** True iff `Shade.importBackup` ran and returned. */
applied: boolean;
/** Guardians that returned grants. */
granted: ReadonlyArray<string>;
/** Guardians that explicitly declined; absent guardians are not listed here. */
declined: ReadonlyArray<{ address: string; reason: string }>;
/** Setup fingerprint of the original device at setup time. Hand to UI. */
setupFingerprint: string | null;
/**
* Recovered safety number. Will equal the original device's pre-loss
* fingerprint (since we restored the same identity). Compare to the
* pre-loss value the user has on a recovery card / second device for
* sanity-checking.
*/
restoredFingerprint: string;
}
interface PendingShare {
share: ShamirShare;
backupBlob: string;
setupFingerprint?: string;
fromAddress: string;
}
/**
* Drive the recovery flow to completion. On success the new Shade
* instance now hosts the original identity (its prior temporary
* identity is overwritten). On failure throws a typed
* {@link RecoveryError} subclass — never `applied: false`.
*/
export async function requestRecovery(opts: RequestRecoveryOptions): Promise<RecoveryResult> {
validateOptions(opts);
const now = opts.now ?? Date.now;
const timeoutMs = opts.timeoutMs ?? 5 * 60 * 1000;
const requesterFingerprint = await opts.shade.fingerprint;
const flowId = `recover:${opts.setupId}:${bytesToBase64Url(crypto.getRandomValues(new Uint8Array(8)))}`;
// Buckets for collecting responses.
const grants = new Map<string, PendingShare>();
const declines: Array<{ address: string; reason: string }> = [];
const guardianSet = new Set(opts.guardians);
let resolveCollection!: (value: 'enough' | 'declined-out' | 'timeout') => void;
const collected = new Promise<'enough' | 'declined-out' | 'timeout'>((resolve) => {
resolveCollection = resolve;
});
// Fingerprint we're waiting to see at setup-time. Filled as soon as
// any grant lands so subsequent grants can be sanity-checked.
let firstSetupFingerprint: string | null = null;
const detach = opts.shade.onMessage(async (from, plaintext) => {
if (!guardianSet.has(from)) return;
const env = safeParse(plaintext);
if (env === null) return;
if (env.flowId !== flowId) return;
if (env.type === 'share-grant') {
if (grants.has(from)) return; // ignore duplicates
try {
const shareBytes = base64UrlToBytes(env.shareBytes);
const share = decodeShare(shareBytes);
const pending: PendingShare = {
share,
backupBlob: env.backupBlob,
fromAddress: from,
};
if (firstSetupFingerprint === null) {
// Shape from the share-grant doesn't include setupFingerprint;
// we'll fall back to whatever importBackup reports.
firstSetupFingerprint = null;
}
grants.set(from, pending);
} catch {
// Malformed share — count this as an implicit decline.
declines.push({ address: from, reason: 'malformed share-bytes' });
}
} else if (env.type === 'share-decline') {
if (grants.has(from)) return;
declines.push({ address: from, reason: env.reason });
} else {
return;
}
opts.onProgress?.({
granted: grants.size,
declined: declines.length,
pending: opts.guardians.length - grants.size - declines.length,
threshold: opts.threshold,
fromAddress: from,
latest: env.type === 'share-grant' ? 'grant' : 'decline',
});
if (grants.size >= opts.threshold) {
resolveCollection('enough');
return;
}
if (opts.guardians.length - declines.length < opts.threshold) {
resolveCollection('declined-out');
}
});
// Send recovery-request to every guardian. Failures count as
// implicit declines (the network couldn't reach them); we keep
// going so partial reachability still clears the threshold.
const requestedAt = now();
for (const guardian of opts.guardians) {
const env: RecoveryRequestEnvelope = {
shadeRecovery: 1,
type: 'recovery-request',
flowId,
originalAddress: opts.originalAddress,
setupId: opts.setupId,
requesterFingerprint,
requestedAt,
};
const plaintext = encodeRecoveryEnvelope(env);
try {
const envelope: ShadeEnvelope = await opts.shade.send(guardian, plaintext);
await opts.deliver(guardian, envelope);
} catch (err) {
declines.push({
address: guardian,
reason: `delivery failed: ${(err as Error).message}`,
});
opts.onProgress?.({
granted: grants.size,
declined: declines.length,
pending: opts.guardians.length - grants.size - declines.length,
threshold: opts.threshold,
fromAddress: guardian,
latest: 'decline',
});
}
}
// Already over-quota on declines from delivery failures alone.
if (opts.guardians.length - declines.length < opts.threshold) {
resolveCollection('declined-out');
}
// Race the collection against the timeout.
let timer: ReturnType<typeof setTimeout> | null = null;
if (Number.isFinite(timeoutMs)) {
timer = setTimeout(() => resolveCollection('timeout'), timeoutMs);
}
let outcome: 'enough' | 'declined-out' | 'timeout';
try {
outcome = await collected;
} finally {
if (timer !== null) clearTimeout(timer);
detach();
}
if (outcome === 'declined-out') {
throw new RecoveryDeclinedError(
declines.map((d) => d.address),
opts.threshold,
opts.guardians.length - declines.length,
);
}
if (outcome === 'timeout') {
throw new RecoveryTimeoutError(grants.size, opts.threshold);
}
// Reconstruct. Try the natural subset first; if a guardian forged
// their share, retry with `threshold`-sized subsets until one
// authenticates against the AES-GCM tag inside the backup blob.
const granted = Array.from(grants.values());
const result = await tryReconstruct(opts.shade, granted, opts.threshold);
if (result === null) {
throw new RecoveryReconstructionError(
'none of the threshold-sized share subsets authenticated the backup blob — ' +
'at least one guardian likely supplied a forged share',
);
}
const restoredFingerprint = await opts.shade.fingerprint;
return {
applied: true,
granted: granted.map((g) => g.fromAddress),
declined: declines.slice(),
setupFingerprint: firstSetupFingerprint,
restoredFingerprint,
};
}
function safeParse(plaintext: string): ReturnType<typeof tryParseRecoveryEnvelope> {
try {
return tryParseRecoveryEnvelope(plaintext);
} catch {
return null;
}
}
/**
* Attempt reconstruction with successively-chosen `threshold`-sized
* subsets of `granted`. The first subset whose combined recoveryKey
* authenticates the backup blob wins. Returns `null` if every subset
* is rejected by the AEAD.
*
* In the honest case the very first subset (the natural-order
* threshold-many) succeeds and we exit immediately. In the adversarial
* case where one or more guardians supplied forged shares, we
* exhaustively try all C(granted, threshold) subsets — bounded above
* by C(255, 128) which is finite but large, so callers SHOULD cap the
* size of the granted pool to a sane maximum (e.g. threshold + 2)
* before invoking this. Inside `requestRecovery` we never collect more
* than `threshold` grants because we stop the listener as soon as the
* threshold lands; the iterative branch only fires when the caller
* passes a pre-collected set.
*/
async function tryReconstruct(
shade: Shade,
granted: ReadonlyArray<PendingShare>,
threshold: number,
): Promise<{ backupBlob: string } | null> {
if (granted.length < threshold) return null;
// All grants must agree on the backupBlob — if a guardian shipped a
// different blob, we treat it as adversarial and skip its share.
const groups = new Map<string, PendingShare[]>();
for (const g of granted) {
const bucket = groups.get(g.backupBlob);
if (bucket === undefined) groups.set(g.backupBlob, [g]);
else bucket.push(g);
}
for (const [backupBlob, group] of groups) {
if (group.length < threshold) continue;
const subsets = subsetsOfSize(group, threshold);
for (const subset of subsets) {
const recoveryKey = combineShares(subset.map((s) => s.share));
try {
const passphrase = recoveryKeyToBackupPassphrase(recoveryKey);
await shade.importBackup(backupBlob, passphrase);
return { backupBlob };
} catch {
// Wrong combination — keep trying other subsets.
continue;
} finally {
recoveryKey.fill(0);
}
}
}
return null;
}
function* subsetsOfSize<T>(items: ReadonlyArray<T>, size: number): IterableIterator<T[]> {
if (size <= 0) {
yield [];
return;
}
if (items.length < size) return;
const indices = new Array<number>(size);
for (let i = 0; i < size; i++) indices[i] = i;
while (true) {
yield indices.map((i) => items[i]!);
// advance
let i = size - 1;
while (i >= 0 && indices[i]! === items.length - size + i) i--;
if (i < 0) return;
indices[i] = indices[i]! + 1;
for (let j = i + 1; j < size; j++) indices[j] = indices[j - 1]! + 1;
}
}
function validateOptions(opts: RequestRecoveryOptions): void {
if (typeof opts.threshold !== 'number' || !Number.isInteger(opts.threshold) || opts.threshold < 1) {
throw new RangeError('requestRecovery: threshold must be an integer ≥ 1');
}
if (!Array.isArray(opts.guardians) || opts.guardians.length === 0) {
throw new RangeError('requestRecovery: guardians must be a non-empty array');
}
if (opts.guardians.length < opts.threshold) {
throw new RangeError(
`requestRecovery: guardians.length (${opts.guardians.length}) must be ≥ threshold (${opts.threshold})`,
);
}
if (new Set(opts.guardians).size !== opts.guardians.length) {
throw new RangeError('requestRecovery: guardians must be unique');
}
if (opts.guardians.includes(opts.shade.myAddress)) {
throw new RangeError('requestRecovery: cannot include your own address as a guardian');
}
if (typeof opts.deliver !== 'function') {
throw new TypeError('requestRecovery: deliver must be a function');
}
if (typeof opts.originalAddress !== 'string' || opts.originalAddress.length === 0) {
throw new TypeError('requestRecovery: originalAddress must be a non-empty string');
}
if (typeof opts.setupId !== 'string' || opts.setupId.length === 0) {
throw new TypeError('requestRecovery: setupId must be a non-empty string');
}
}