release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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
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
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>
This commit is contained in:
229
packages/shade-recovery/src/setup.ts
Normal file
229
packages/shade-recovery/src/setup.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user