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:
63
packages/shade-recovery/README.md
Normal file
63
packages/shade-recovery/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# `@shade/recovery`
|
||||
|
||||
Social key recovery for Shade — V3.10.
|
||||
|
||||
Shamir Secret Sharing over GF(2^8) splits the user's identity backup
|
||||
key into `n` shares; any threshold-many `k` together reconstruct the
|
||||
identity onto a new device. Distribution and reconstruction ride
|
||||
existing 1:1 Shade sessions — no centralized recovery agent.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @shade/recovery
|
||||
```
|
||||
|
||||
## Quick wire-up
|
||||
|
||||
```ts
|
||||
import {
|
||||
setupRecovery,
|
||||
attachGuardian,
|
||||
requestRecovery,
|
||||
MemoryRecoveryStore,
|
||||
} from '@shade/recovery';
|
||||
|
||||
// Primary (Alice's existing device)
|
||||
await setupRecovery({
|
||||
shade,
|
||||
guardians: ['bob', 'carol', 'dan', 'eve', 'faythe'],
|
||||
threshold: 3,
|
||||
deliver: async (to, envelope) => myOutbox.send(to, envelope),
|
||||
});
|
||||
|
||||
// Each guardian
|
||||
attachGuardian({
|
||||
shade,
|
||||
store: new MemoryRecoveryStore(), // swap for persistent store in prod
|
||||
approve: async (ctx) => askUser(ctx),
|
||||
deliver: async (to, envelope) => myOutbox.send(to, envelope),
|
||||
});
|
||||
|
||||
// New device (Alice on a fresh phone)
|
||||
await requestRecovery({
|
||||
shade: tempShade,
|
||||
originalAddress: 'alice',
|
||||
setupId: '<from recovery card>',
|
||||
threshold: 3,
|
||||
guardians: ['bob', 'carol', 'dan', 'eve', 'faythe'],
|
||||
deliver: async (to, envelope) => myOutbox.send(to, envelope),
|
||||
});
|
||||
```
|
||||
|
||||
See [`docs/recovery.md`](../../docs/recovery.md) for the full
|
||||
threat model, persistence recommendations, and guardian-UX guidance.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
bun test # all
|
||||
bun test tests/shamir # Shamir primitives
|
||||
bun test tests/integration # 3-of-5 end-to-end
|
||||
bun test tests/adversarial # k-1 collusion + forged shares + OOB-gate
|
||||
```
|
||||
22
packages/shade-recovery/package.json
Normal file
22
packages/shade-recovery/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@shade/recovery",
|
||||
"version": "4.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/crypto-web": "workspace:*",
|
||||
"@shade/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shade/server": "workspace:*",
|
||||
"fast-check": "^3.22.0"
|
||||
}
|
||||
}
|
||||
45
packages/shade-recovery/src/encoding.ts
Normal file
45
packages/shade-recovery/src/encoding.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Encoding helpers shared by the setup, request, and guardian modules.
|
||||
* Kept as a tiny standalone module so individual flows don't carry
|
||||
* private base64 helpers; consistent encoding across send/receive
|
||||
* sides.
|
||||
*/
|
||||
|
||||
/** Base64url (no padding) — used for both `recoveryKey → passphrase` and arbitrary share bytes. */
|
||||
export function bytesToBase64Url(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function base64UrlToBytes(s: string): Uint8Array {
|
||||
const padded = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = padded.length % 4 === 0 ? 0 : 4 - (padded.length % 4);
|
||||
const bin = atob(padded + '='.repeat(padding));
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a `recoveryKey` (32 random bytes) to the passphrase that
|
||||
* `Shade.exportBackup` / `Shade.importBackup` expect. We use base64url
|
||||
* because:
|
||||
* - it's a string, satisfying the export/import API,
|
||||
* - 32 bytes encodes to 43 characters, comfortably above the 12-char
|
||||
* minimum the exportBackup helper enforces,
|
||||
* - the encoding is deterministic so split + reconstruct + decode
|
||||
* yields the identical passphrase the original device used.
|
||||
*
|
||||
* The HKDF inside `exportBackup` is a deterministic KDF that's
|
||||
* cryptographically appropriate for a 32-byte uniformly-random IKM
|
||||
* (this is exactly the standard HKDF use case). The fact that the
|
||||
* passphrase API was designed for human-typed passwords does not
|
||||
* weaken the construction here.
|
||||
*/
|
||||
export function recoveryKeyToBackupPassphrase(key: Uint8Array): string {
|
||||
if (key.length !== 32) {
|
||||
throw new Error(`recoveryKey must be 32 bytes (got ${key.length})`);
|
||||
}
|
||||
return `shade-rk:${bytesToBase64Url(key)}`;
|
||||
}
|
||||
90
packages/shade-recovery/src/errors.ts
Normal file
90
packages/shade-recovery/src/errors.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Errors emitted by `@shade/recovery`. All of them subclass the same base
|
||||
* so consumers can catch any recovery-related failure in one block, then
|
||||
* branch on the concrete type for messaging.
|
||||
*/
|
||||
export class RecoveryError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = 'RecoveryError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The new-device flow timed out while waiting for the threshold number
|
||||
* of guardians to respond. Retryable — the caller can ask the user to
|
||||
* nudge guardians offline and retry.
|
||||
*/
|
||||
export class RecoveryTimeoutError extends RecoveryError {
|
||||
constructor(
|
||||
public readonly received: number,
|
||||
public readonly threshold: number,
|
||||
) {
|
||||
super(
|
||||
`Recovery timed out: received ${received} guardian responses, need ${threshold}`,
|
||||
);
|
||||
this.name = 'RecoveryTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One or more guardians explicitly declined the recovery request. Listed
|
||||
* in `declines` (guardian addresses). The new-device flow keeps running
|
||||
* with the remaining guardians; this error fires only when too many
|
||||
* decline to ever reach the threshold.
|
||||
*/
|
||||
export class RecoveryDeclinedError extends RecoveryError {
|
||||
constructor(
|
||||
public readonly declines: ReadonlyArray<string>,
|
||||
public readonly threshold: number,
|
||||
public readonly remaining: number,
|
||||
) {
|
||||
super(
|
||||
`Recovery aborted: ${declines.length} guardian(s) declined and ${remaining} are left, ` +
|
||||
`which is below the threshold of ${threshold}`,
|
||||
);
|
||||
this.name = 'RecoveryDeclinedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reconstructed `recoveryKey` did not authenticate the encrypted
|
||||
* `shareSecret` (AEAD tag mismatch). Most likely cause: a guardian
|
||||
* supplied a forged share, OR the user supplied the wrong original
|
||||
* address. Treat as adversarial — abort and notify the user, do not
|
||||
* retry with the same shares.
|
||||
*/
|
||||
export class RecoveryReconstructionError extends RecoveryError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = 'RecoveryReconstructionError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The guardian-side approve callback returned `false` (or threw),
|
||||
* indicating the user did not match the OOB safety number. The peer
|
||||
* receives a `share-decline` envelope; this error is thrown locally on
|
||||
* the new device when its requestRecovery() detects too many declines.
|
||||
*/
|
||||
export class RecoveryGuardianRejectedError extends RecoveryError {
|
||||
constructor(
|
||||
public readonly guardianAddress: string,
|
||||
public readonly reason: string,
|
||||
) {
|
||||
super(`Guardian ${guardianAddress} rejected the recovery request: ${reason}`);
|
||||
this.name = 'RecoveryGuardianRejectedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A protocol envelope arrived that could not be parsed (malformed JSON,
|
||||
* missing required field, unknown version, etc.). Always treat as a bug
|
||||
* or as malicious input — never silently ignore.
|
||||
*/
|
||||
export class RecoveryProtocolError extends RecoveryError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = 'RecoveryProtocolError';
|
||||
}
|
||||
}
|
||||
250
packages/shade-recovery/src/guardian.ts
Normal file
250
packages/shade-recovery/src/guardian.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Guardian-side receiver.
|
||||
*
|
||||
* `attachGuardian` wires a `Shade.onMessage` handler that handles every
|
||||
* recovery envelope addressed to this device:
|
||||
*
|
||||
* - `share-deposit`: persist the share + backup blob in the supplied
|
||||
* `RecoveryStore`. Idempotent on (originalAddress, setupId).
|
||||
*
|
||||
* - `recovery-request`: invoke the `approve` callback with the
|
||||
* ratcheted requester address + the safety number embedded in the
|
||||
* envelope. The approve callback is the user-confronted gate — it
|
||||
* SHOULD pop UI showing both fingerprints and require an explicit
|
||||
* OOB-confirmed click. Returning `true` ships a `share-grant`;
|
||||
* returning `false` (or throwing) ships a `share-decline` with the
|
||||
* reason. Recovery-requests for unknown originalAddress/setupId
|
||||
* pairs are auto-declined with `"unknown setup"`.
|
||||
*
|
||||
* - All other recovery types are ignored (we only care about the
|
||||
* guardian's inbound side; share-grant + share-decline target the
|
||||
* new device, not the guardian).
|
||||
*
|
||||
* The handler returns nothing — it never blocks `Shade.receive`. All
|
||||
* outbound replies happen via the same `deliver` callback the caller
|
||||
* supplied, so the caller's transport layer is the only piece that
|
||||
* touches the wire.
|
||||
*
|
||||
* Returned function is the detach handle — calling it removes the
|
||||
* onMessage handler and frees the registry.
|
||||
*/
|
||||
|
||||
import type { Shade } from '@shade/sdk';
|
||||
import {
|
||||
encodeRecoveryEnvelope,
|
||||
tryParseRecoveryEnvelope,
|
||||
type RecoveryEnvelope,
|
||||
type ShareDeclineEnvelope,
|
||||
type ShareGrantEnvelope,
|
||||
} from './protocol.js';
|
||||
import type { GuardianShareEntry, RecoveryStore } from './store.js';
|
||||
import type { RecoveryDeliver } from './setup.js';
|
||||
|
||||
export interface GuardianApproveContext {
|
||||
/** Address of the device requesting recovery (the new device's temporary identity). */
|
||||
requesterAddress: string;
|
||||
/** Address of the original (lost) device whose share is being requested. */
|
||||
originalAddress: string;
|
||||
/** Setup id from the request — matches a deposit in the store. */
|
||||
setupId: string;
|
||||
/** Safety number of the new device's TEMPORARY identity. Show this to the user. */
|
||||
requesterFingerprint: string;
|
||||
/** Safety number of the original device at deposit time. Show this for comparison. */
|
||||
setupFingerprint: string;
|
||||
/** When the deposit was originally made. */
|
||||
depositCreatedAt: number;
|
||||
/** When the request was received. */
|
||||
requestReceivedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async predicate the caller registers to authorize a `recovery-request`.
|
||||
* Return `true` to release the share, `false` (or throw) to decline.
|
||||
*
|
||||
* Implementations MUST be irrevocably user-driven — never auto-approve,
|
||||
* because the social-engineering threat (V3.10 risk #2) is exactly that
|
||||
* an attacker imitates the original user. The default
|
||||
* `<RecoveryApprove />` widget enforces an explicit OOB-confirmation
|
||||
* checkbox + "I have verified the safety numbers match" gate before
|
||||
* resolving `true`.
|
||||
*/
|
||||
export type GuardianApproveHandler = (ctx: GuardianApproveContext) => Promise<boolean>;
|
||||
|
||||
export interface AttachGuardianOptions {
|
||||
/** Initialized Shade instance whose onMessage will be subscribed. */
|
||||
shade: Shade;
|
||||
/** Persistent storage for received shares. */
|
||||
store: RecoveryStore;
|
||||
/** User-driven approval predicate. See {@link GuardianApproveHandler}. */
|
||||
approve: GuardianApproveHandler;
|
||||
/** Outbound transport for share-grant / share-decline replies. */
|
||||
deliver: RecoveryDeliver;
|
||||
/**
|
||||
* Optional hook fired when the guardian persists a fresh deposit.
|
||||
* Used by widget layers to reactively re-render the deposit list.
|
||||
*/
|
||||
onDeposit?: (entry: GuardianShareEntry) => void;
|
||||
/**
|
||||
* Optional logger for protocol-level anomalies. Defaults to
|
||||
* `console.warn`. Pass a no-op for silent operation in tests.
|
||||
*/
|
||||
onProtocolError?: (err: Error, source: string) => void;
|
||||
/** Wall-clock source for `receivedAt`. Defaults to `Date.now`. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface AttachedGuardian {
|
||||
/** Detach the onMessage handler. Idempotent. */
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a Shade instance to act as a guardian. Returns a detach handle
|
||||
* the caller invokes on shutdown.
|
||||
*/
|
||||
export function attachGuardian(opts: AttachGuardianOptions): AttachedGuardian {
|
||||
if (typeof opts.deliver !== 'function') {
|
||||
throw new TypeError('attachGuardian: deliver must be a function');
|
||||
}
|
||||
if (typeof opts.approve !== 'function') {
|
||||
throw new TypeError('attachGuardian: approve must be a function');
|
||||
}
|
||||
const onProtocolError = opts.onProtocolError ?? defaultProtocolErrorLogger;
|
||||
const now = opts.now ?? Date.now;
|
||||
|
||||
const detach = opts.shade.onMessage(async (from, plaintext) => {
|
||||
let env: RecoveryEnvelope | null;
|
||||
try {
|
||||
env = tryParseRecoveryEnvelope(plaintext);
|
||||
} catch (err) {
|
||||
onProtocolError(err as Error, `from=${from}`);
|
||||
return;
|
||||
}
|
||||
if (env === null) return; // not a recovery message — ignore.
|
||||
|
||||
try {
|
||||
switch (env.type) {
|
||||
case 'share-deposit':
|
||||
await handleDeposit(opts, env, now());
|
||||
return;
|
||||
case 'recovery-request':
|
||||
await handleRequest(opts, from, env, now());
|
||||
return;
|
||||
case 'share-grant':
|
||||
case 'share-decline':
|
||||
// Replies belong to the new-device flow — guardians don't act on them.
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
onProtocolError(err as Error, `type=${env.type} from=${from}`);
|
||||
}
|
||||
});
|
||||
|
||||
let stopped = false;
|
||||
return {
|
||||
stop: () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
detach();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function handleDeposit(
|
||||
opts: AttachGuardianOptions,
|
||||
env: import('./protocol.js').ShareDepositEnvelope,
|
||||
receivedAt: number,
|
||||
): Promise<void> {
|
||||
const entry: GuardianShareEntry = {
|
||||
originalAddress: env.originalAddress,
|
||||
setupId: env.setupId,
|
||||
shareIndex: env.shareIndex,
|
||||
shareBytes: env.shareBytes,
|
||||
// The backupBlob string contains the AES-GCM-protected payload; we
|
||||
// split it into "shareSecret" fields per the V3.10 schema for
|
||||
// legibility while keeping the original blob string intact.
|
||||
shareSecretCiphertext: env.backupBlob,
|
||||
shareSecretNonce: '',
|
||||
setupFingerprint: env.setupFingerprint,
|
||||
guardianCount: env.guardianCount,
|
||||
threshold: env.threshold,
|
||||
receivedAt,
|
||||
};
|
||||
await opts.store.save(entry);
|
||||
opts.onDeposit?.(entry);
|
||||
}
|
||||
|
||||
async function handleRequest(
|
||||
opts: AttachGuardianOptions,
|
||||
from: string,
|
||||
env: import('./protocol.js').RecoveryRequestEnvelope,
|
||||
receivedAt: number,
|
||||
): Promise<void> {
|
||||
const stored = await opts.store.get(env.originalAddress, env.setupId);
|
||||
if (stored === null) {
|
||||
await sendDecline(opts, from, env, 'unknown setup');
|
||||
return;
|
||||
}
|
||||
|
||||
let approved: boolean;
|
||||
try {
|
||||
approved = await opts.approve({
|
||||
requesterAddress: from,
|
||||
originalAddress: env.originalAddress,
|
||||
setupId: env.setupId,
|
||||
requesterFingerprint: env.requesterFingerprint,
|
||||
setupFingerprint: stored.setupFingerprint,
|
||||
depositCreatedAt: stored.receivedAt,
|
||||
requestReceivedAt: receivedAt,
|
||||
});
|
||||
} catch (err) {
|
||||
await sendDecline(
|
||||
opts,
|
||||
from,
|
||||
env,
|
||||
`approve handler threw: ${(err as Error).message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!approved) {
|
||||
await sendDecline(opts, from, env, 'user declined');
|
||||
return;
|
||||
}
|
||||
|
||||
const grant: ShareGrantEnvelope = {
|
||||
shadeRecovery: 1,
|
||||
type: 'share-grant',
|
||||
flowId: env.flowId,
|
||||
originalAddress: env.originalAddress,
|
||||
setupId: env.setupId,
|
||||
shareIndex: stored.shareIndex,
|
||||
shareBytes: stored.shareBytes,
|
||||
backupBlob: stored.shareSecretCiphertext,
|
||||
};
|
||||
const plaintext = encodeRecoveryEnvelope(grant);
|
||||
const envelope = await opts.shade.send(from, plaintext);
|
||||
await opts.deliver(from, envelope);
|
||||
}
|
||||
|
||||
async function sendDecline(
|
||||
opts: AttachGuardianOptions,
|
||||
to: string,
|
||||
env: import('./protocol.js').RecoveryRequestEnvelope,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
const decline: ShareDeclineEnvelope = {
|
||||
shadeRecovery: 1,
|
||||
type: 'share-decline',
|
||||
flowId: env.flowId,
|
||||
originalAddress: env.originalAddress,
|
||||
setupId: env.setupId,
|
||||
reason,
|
||||
};
|
||||
const plaintext = encodeRecoveryEnvelope(decline);
|
||||
const envelope = await opts.shade.send(to, plaintext);
|
||||
await opts.deliver(to, envelope);
|
||||
}
|
||||
|
||||
function defaultProtocolErrorLogger(err: Error, source: string): void {
|
||||
console.warn(`[shade-recovery] guardian dropped malformed envelope (${source}): ${err.message}`);
|
||||
}
|
||||
77
packages/shade-recovery/src/index.ts
Normal file
77
packages/shade-recovery/src/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* `@shade/recovery` — social key recovery for Shade (V3.10).
|
||||
*
|
||||
* Public surface:
|
||||
* - {@link setupRecovery}: distribute Shamir shares to guardians.
|
||||
* - {@link attachGuardian}: wire a guardian-side receiver.
|
||||
* - {@link requestRecovery}: rebuild a lost identity from threshold guardians.
|
||||
* - {@link splitSecret} / {@link combineShares}: low-level Shamir primitives
|
||||
* (exported for advanced callers and test harnesses).
|
||||
* - Errors, store interface, and protocol envelope types.
|
||||
*/
|
||||
|
||||
// Core flows
|
||||
export { setupRecovery } from './setup.js';
|
||||
export type {
|
||||
SetupRecoveryOptions,
|
||||
SetupRecoveryResult,
|
||||
GuardianDelivery,
|
||||
RecoveryDeliver,
|
||||
} from './setup.js';
|
||||
|
||||
export { attachGuardian } from './guardian.js';
|
||||
export type {
|
||||
AttachGuardianOptions,
|
||||
AttachedGuardian,
|
||||
GuardianApproveContext,
|
||||
GuardianApproveHandler,
|
||||
} from './guardian.js';
|
||||
|
||||
export { requestRecovery } from './request.js';
|
||||
export type {
|
||||
RequestRecoveryOptions,
|
||||
RecoveryProgress,
|
||||
RecoveryResult,
|
||||
} from './request.js';
|
||||
|
||||
// Storage
|
||||
export { MemoryRecoveryStore } from './store.js';
|
||||
export type { GuardianShareEntry, RecoveryStore } from './store.js';
|
||||
|
||||
// Errors
|
||||
export {
|
||||
RecoveryError,
|
||||
RecoveryDeclinedError,
|
||||
RecoveryGuardianRejectedError,
|
||||
RecoveryProtocolError,
|
||||
RecoveryReconstructionError,
|
||||
RecoveryTimeoutError,
|
||||
} from './errors.js';
|
||||
|
||||
// Protocol — exported for apps that need to inspect or relay envelopes.
|
||||
export {
|
||||
encodeRecoveryEnvelope,
|
||||
tryParseRecoveryEnvelope,
|
||||
RECOVERY_DISCRIMINATOR,
|
||||
RECOVERY_PROTOCOL_VERSION,
|
||||
} from './protocol.js';
|
||||
export type {
|
||||
RecoveryEnvelope,
|
||||
RecoveryMessageType,
|
||||
RecoveryRequestEnvelope,
|
||||
ShareDeclineEnvelope,
|
||||
ShareDepositEnvelope,
|
||||
ShareGrantEnvelope,
|
||||
} from './protocol.js';
|
||||
|
||||
// Shamir primitives — exported for tests and advanced callers (e.g.
|
||||
// hardware-token integrations that want to split a different secret).
|
||||
export { splitSecret, combineShares, encodeShare, decodeShare } from './shamir.js';
|
||||
export type { ShamirShare } from './shamir.js';
|
||||
|
||||
// Encoding helpers — used by widget-layer code and integration tests.
|
||||
export {
|
||||
bytesToBase64Url,
|
||||
base64UrlToBytes,
|
||||
recoveryKeyToBackupPassphrase,
|
||||
} from './encoding.js';
|
||||
245
packages/shade-recovery/src/protocol.ts
Normal file
245
packages/shade-recovery/src/protocol.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Wire protocol for `@shade/recovery`.
|
||||
*
|
||||
* Every payload is a JSON object that fits inside a single
|
||||
* `Shade.send(plaintext)` call (we travel over the existing 1:1
|
||||
* Double-Ratchet sessions — no new transport). All envelopes carry a
|
||||
* `shadeRecovery` discriminator so a guardian-side `Shade.onMessage`
|
||||
* handler can cheaply skip non-recovery traffic without misparsing it.
|
||||
*
|
||||
* Envelope versions:
|
||||
* - v1: initial release (V3.10).
|
||||
*
|
||||
* Message types:
|
||||
*
|
||||
* share-deposit primary → guardian (during setup)
|
||||
* The guardian receives one Shamir share for the primary user's
|
||||
* identity, plus the AEAD-protected `backupBlob` string (duplicated
|
||||
* to every guardian so any threshold subset can reconstruct).
|
||||
*
|
||||
* recovery-request new-device → guardian
|
||||
* Asks the guardian to release its stored share. The new device's
|
||||
* temporary identity fingerprint is included so the guardian can
|
||||
* OOB-confirm before approving.
|
||||
*
|
||||
* share-grant guardian → new-device
|
||||
* The guardian's response when its approve handler returns true:
|
||||
* ships the share + backupBlob back to the new device.
|
||||
*
|
||||
* share-decline guardian → new-device
|
||||
* The guardian's response when the user (or a hard policy) refused
|
||||
* to release the share. Carries a short reason for diagnostics.
|
||||
*
|
||||
* The encoder is deliberately a thin JSON serializer with stable keys —
|
||||
* we do NOT need canonical hashing here because every payload is already
|
||||
* authenticated by the underlying Double-Ratchet AEAD. Stability matters
|
||||
* only for forward-compatible parsing (tolerate future, additive fields
|
||||
* but reject malformed ones).
|
||||
*
|
||||
* `backupBlob` is opaque from this module's perspective — it's whatever
|
||||
* `Shade.exportBackup(passphrase, addresses)` produces, where the
|
||||
* `passphrase` is a base64url encoding of the random `recoveryKey` that
|
||||
* was Shamir-split. The new device combines shares to recover the key,
|
||||
* derives the same passphrase, and calls `Shade.importBackup`. The
|
||||
* AES-GCM authentication tag inside the backup blob doubles as the
|
||||
* sentinel for a successful reconstruction.
|
||||
*/
|
||||
|
||||
import { RecoveryProtocolError } from './errors.js';
|
||||
|
||||
export const RECOVERY_PROTOCOL_VERSION = 1;
|
||||
export const RECOVERY_DISCRIMINATOR = 'shadeRecovery';
|
||||
|
||||
export type RecoveryMessageType =
|
||||
| 'share-deposit'
|
||||
| 'recovery-request'
|
||||
| 'share-grant'
|
||||
| 'share-decline';
|
||||
|
||||
interface BaseEnvelope<T extends RecoveryMessageType> {
|
||||
shadeRecovery: 1;
|
||||
type: T;
|
||||
/** Stable per-flow identifier; correlates request → grant/decline. */
|
||||
flowId: string;
|
||||
}
|
||||
|
||||
export interface ShareDepositEnvelope extends BaseEnvelope<'share-deposit'> {
|
||||
/** Address of the primary (the user being protected). */
|
||||
originalAddress: string;
|
||||
/** Stable session id — primary chooses; same across all guardians in this setup. */
|
||||
setupId: string;
|
||||
/** Threshold this setup was created with. */
|
||||
threshold: number;
|
||||
/** Number of guardians in this setup. */
|
||||
guardianCount: number;
|
||||
/** Index of THIS guardian in 1..guardianCount. Matches the Shamir x-coordinate. */
|
||||
shareIndex: number;
|
||||
/** Base64-encoded `encodeShare(...)` bytes (1 byte x + N bytes y). */
|
||||
shareBytes: string;
|
||||
/**
|
||||
* Output of `Shade.exportBackup(passphrase, knownAddresses)`. Opaque
|
||||
* string of the form `shade-backup:v1:<salt>:<nonce>:<ciphertext>`.
|
||||
* Stored verbatim by the guardian and shipped back on
|
||||
* `share-grant`. Identical for every guardian in the same setup.
|
||||
*/
|
||||
backupBlob: string;
|
||||
/** Original-device fingerprint at setup time — guardians can persist for sanity-checks. */
|
||||
setupFingerprint: string;
|
||||
/** Unix-ms timestamp at setup time. */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface RecoveryRequestEnvelope extends BaseEnvelope<'recovery-request'> {
|
||||
/** Address of the original (lost) identity. The guardian uses this to look up its stored share. */
|
||||
originalAddress: string;
|
||||
/** Setup id for the share the new device wants. Lets a guardian disambiguate multiple deposits. */
|
||||
setupId: string;
|
||||
/** Safety number of the *temporary* identity making the request. */
|
||||
requesterFingerprint: string;
|
||||
/** Unix-ms timestamp; receiver MAY enforce a freshness window. */
|
||||
requestedAt: number;
|
||||
}
|
||||
|
||||
export interface ShareGrantEnvelope extends BaseEnvelope<'share-grant'> {
|
||||
/** Echoes the request's originalAddress so the receiver can route it. */
|
||||
originalAddress: string;
|
||||
/** Echoes setupId. */
|
||||
setupId: string;
|
||||
/** Index 1..guardianCount — same as the deposit's shareIndex. */
|
||||
shareIndex: number;
|
||||
/** Base64-encoded `encodeShare(...)` bytes. */
|
||||
shareBytes: string;
|
||||
/** Verbatim copy of the backup blob the guardian stored at setup time. */
|
||||
backupBlob: string;
|
||||
}
|
||||
|
||||
export interface ShareDeclineEnvelope extends BaseEnvelope<'share-decline'> {
|
||||
originalAddress: string;
|
||||
setupId: string;
|
||||
/** Short, user-visible reason ("fingerprint mismatch", "user declined", "no share for this address"). */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export type RecoveryEnvelope =
|
||||
| ShareDepositEnvelope
|
||||
| RecoveryRequestEnvelope
|
||||
| ShareGrantEnvelope
|
||||
| ShareDeclineEnvelope;
|
||||
|
||||
/**
|
||||
* Serialize an envelope to the JSON string that goes through
|
||||
* `Shade.send`. Throws if the input is structurally invalid (defensive —
|
||||
* shouldn't fire under normal flows, but cheap to keep).
|
||||
*/
|
||||
export function encodeRecoveryEnvelope(env: RecoveryEnvelope): string {
|
||||
validateEnvelope(env);
|
||||
return JSON.stringify(env);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string payload from `Shade.onMessage`. Returns `null` when the
|
||||
* payload is not a recovery envelope (so guardian apps can multiplex
|
||||
* recovery + non-recovery traffic on the same handler). Throws
|
||||
* `RecoveryProtocolError` on payloads that *claim* to be recovery
|
||||
* envelopes but are malformed.
|
||||
*/
|
||||
export function tryParseRecoveryEnvelope(plaintext: string): RecoveryEnvelope | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(plaintext);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(parsed)) return null;
|
||||
if (parsed[RECOVERY_DISCRIMINATOR] !== 1) return null;
|
||||
if (typeof parsed.type !== 'string') {
|
||||
throw new RecoveryProtocolError('recovery envelope missing string `type` field');
|
||||
}
|
||||
validateEnvelope(parsed as unknown as RecoveryEnvelope);
|
||||
return parsed as unknown as RecoveryEnvelope;
|
||||
}
|
||||
|
||||
function validateEnvelope(envIn: RecoveryEnvelope): void {
|
||||
const env = envIn as unknown as Record<string, unknown>;
|
||||
if (env.shadeRecovery !== 1) {
|
||||
throw new RecoveryProtocolError(
|
||||
`unsupported recovery envelope version: ${env.shadeRecovery as unknown as string}`,
|
||||
);
|
||||
}
|
||||
if (typeof env.flowId !== 'string' || (env.flowId as string).length === 0) {
|
||||
throw new RecoveryProtocolError('recovery envelope missing flowId');
|
||||
}
|
||||
switch (env.type) {
|
||||
case 'share-deposit': {
|
||||
requireString(env, 'originalAddress');
|
||||
requireString(env, 'setupId');
|
||||
requireFiniteInt(env, 'threshold', 1, 255);
|
||||
requireFiniteInt(env, 'guardianCount', 1, 255);
|
||||
const threshold = env.threshold as number;
|
||||
const guardianCount = env.guardianCount as number;
|
||||
if (threshold > guardianCount) {
|
||||
throw new RecoveryProtocolError('share-deposit: threshold > guardianCount');
|
||||
}
|
||||
requireFiniteInt(env, 'shareIndex', 1, guardianCount);
|
||||
requireString(env, 'shareBytes');
|
||||
requireString(env, 'backupBlob');
|
||||
requireString(env, 'setupFingerprint');
|
||||
requireFinite(env, 'createdAt');
|
||||
break;
|
||||
}
|
||||
case 'recovery-request':
|
||||
requireString(env, 'originalAddress');
|
||||
requireString(env, 'setupId');
|
||||
requireString(env, 'requesterFingerprint');
|
||||
requireFinite(env, 'requestedAt');
|
||||
break;
|
||||
case 'share-grant':
|
||||
requireString(env, 'originalAddress');
|
||||
requireString(env, 'setupId');
|
||||
requireFiniteInt(env, 'shareIndex', 1, 255);
|
||||
requireString(env, 'shareBytes');
|
||||
requireString(env, 'backupBlob');
|
||||
break;
|
||||
case 'share-decline':
|
||||
requireString(env, 'originalAddress');
|
||||
requireString(env, 'setupId');
|
||||
requireString(env, 'reason');
|
||||
break;
|
||||
default:
|
||||
throw new RecoveryProtocolError(
|
||||
`unknown recovery envelope type: ${env.type as unknown as string}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function requireString(env: Record<string, unknown>, key: string): void {
|
||||
const v = env[key];
|
||||
if (typeof v !== 'string' || v.length === 0) {
|
||||
throw new RecoveryProtocolError(`recovery envelope missing string field "${key}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireFinite(env: Record<string, unknown>, key: string): void {
|
||||
const v = env[key];
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
||||
throw new RecoveryProtocolError(`recovery envelope missing numeric field "${key}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireFiniteInt(
|
||||
env: Record<string, unknown>,
|
||||
key: string,
|
||||
min: number,
|
||||
max: number,
|
||||
): void {
|
||||
const v = env[key];
|
||||
if (typeof v !== 'number' || !Number.isInteger(v) || v < min || v > max) {
|
||||
throw new RecoveryProtocolError(
|
||||
`recovery envelope field "${key}" must be an integer in [${min}, ${max}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
387
packages/shade-recovery/src/request.ts
Normal file
387
packages/shade-recovery/src/request.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
226
packages/shade-recovery/src/shamir.ts
Normal file
226
packages/shade-recovery/src/shamir.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Shamir Secret Sharing over GF(2^8).
|
||||
*
|
||||
* Splits a secret byte-array into `n` shares such that any `k` (the
|
||||
* threshold) reconstruct the original, but any combination of `k-1` or
|
||||
* fewer reveals nothing about the secret beyond its length. Each byte of
|
||||
* the secret is shared independently using a random polynomial of degree
|
||||
* `k-1` whose constant term is the secret byte; shares are points on that
|
||||
* polynomial evaluated at `x = 1..n`.
|
||||
*
|
||||
* Field: GF(2^8) with the irreducible polynomial 0x11b (AES'). Tables for
|
||||
* exp and log are precomputed at module load time and reused for both
|
||||
* multiplication and inversion. All field operations are constant-time
|
||||
* (table lookups + xor / mod), so split + combine leak nothing about the
|
||||
* secret bytes through timing.
|
||||
*
|
||||
* The wire format for a share is:
|
||||
*
|
||||
* 1 byte x-coordinate (1..255)
|
||||
* N bytes y-coordinates, one per secret byte
|
||||
*
|
||||
* `splitSecret` returns an array of length `n`. `combineShares` accepts
|
||||
* any subset of shares; the threshold is implicit in the polynomial
|
||||
* degree the sender chose. Combining `k-1` shares yields a different
|
||||
* (random-looking) result — there's no way to detect under-threshold
|
||||
* combination from the shares alone, so callers must enforce the
|
||||
* threshold separately (e.g. by waiting for `k` arrivals before combining)
|
||||
* AND verify the reconstructed key against the AEAD tag of the
|
||||
* ciphertext it was meant to decrypt.
|
||||
*/
|
||||
|
||||
const FIELD_SIZE = 256;
|
||||
|
||||
const EXP = new Uint8Array(FIELD_SIZE * 2);
|
||||
const LOG = new Uint8Array(FIELD_SIZE);
|
||||
|
||||
(function buildTables(): void {
|
||||
// Generator: 0x03. Standard AES-style table buildup.
|
||||
let x = 1;
|
||||
for (let i = 0; i < 255; i++) {
|
||||
EXP[i] = x;
|
||||
LOG[x] = i;
|
||||
// Multiply x by the generator (0x03) in GF(2^8) with the AES
|
||||
// reduction polynomial 0x1b on overflow.
|
||||
let next = x ^ ((x << 1) & 0xff);
|
||||
if (x & 0x80) next ^= 0x1b;
|
||||
x = next & 0xff;
|
||||
}
|
||||
// Mirror the first half so `EXP[i + j]` works for any 0..510 without
|
||||
// an extra modulus.
|
||||
for (let i = 255; i < FIELD_SIZE * 2; i++) EXP[i] = EXP[i - 255]!;
|
||||
})();
|
||||
|
||||
function gfMul(a: number, b: number): number {
|
||||
if (a === 0 || b === 0) return 0;
|
||||
return EXP[LOG[a]! + LOG[b]!]!;
|
||||
}
|
||||
|
||||
function gfDiv(a: number, b: number): number {
|
||||
if (b === 0) throw new Error('Shamir: division by zero');
|
||||
if (a === 0) return 0;
|
||||
// a/b = exp(log(a) - log(b)) — keep the index non-negative by adding 255.
|
||||
return EXP[LOG[a]! - LOG[b]! + 255]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a polynomial `coeffs[0] + coeffs[1]*x + … + coeffs[d]*x^d`
|
||||
* at point `x` using Horner's method. Constant-time in the coefficients
|
||||
* (no early-out on zero terms).
|
||||
*/
|
||||
function evalPoly(coeffs: Uint8Array, x: number): number {
|
||||
let acc = coeffs[coeffs.length - 1]!;
|
||||
for (let i = coeffs.length - 2; i >= 0; i--) {
|
||||
acc = gfMul(acc, x) ^ coeffs[i]!;
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* One Shamir share.
|
||||
*
|
||||
* - `x`: the x-coordinate, 1..255 (unique per share within a split).
|
||||
* - `y`: a y-coordinate for each byte of the original secret.
|
||||
*/
|
||||
export interface ShamirShare {
|
||||
x: number;
|
||||
y: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split `secret` into `n` shares; any `k` reconstructs.
|
||||
*
|
||||
* @param secret the bytes to split (any length ≥ 1)
|
||||
* @param k threshold (1 ≤ k ≤ n ≤ 255)
|
||||
* @param n total number of shares
|
||||
* @param random RNG callback (length → bytes). Must be cryptographically
|
||||
* secure. Inject `crypto.randomBytes.bind(crypto)` from a
|
||||
* `CryptoProvider` to reuse the SDK's RNG.
|
||||
* @returns array of `n` shares with x-coordinates 1..n (skipping 0
|
||||
* because P(0) IS the secret).
|
||||
*/
|
||||
export function splitSecret(
|
||||
secret: Uint8Array,
|
||||
k: number,
|
||||
n: number,
|
||||
random: (length: number) => Uint8Array,
|
||||
): ShamirShare[] {
|
||||
if (!Number.isInteger(k) || !Number.isInteger(n)) {
|
||||
throw new Error('Shamir: k and n must be integers');
|
||||
}
|
||||
if (k < 1) throw new Error('Shamir: threshold must be ≥ 1');
|
||||
if (n < k) throw new Error('Shamir: n must be ≥ k');
|
||||
if (n > 255) throw new Error('Shamir: n must be ≤ 255 (GF(2^8) limit)');
|
||||
if (secret.length === 0) throw new Error('Shamir: secret must be non-empty');
|
||||
|
||||
// Pre-allocate per-share y-buffers. We fill column-wise: for each byte
|
||||
// of the secret we draw a fresh random polynomial of degree k-1 with
|
||||
// constant term equal to the secret byte, then emit y[byteIndex] for
|
||||
// each share at its x-coordinate.
|
||||
const shares: ShamirShare[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
shares.push({ x: i + 1, y: new Uint8Array(secret.length) });
|
||||
}
|
||||
|
||||
// For each column (byte of the secret), build a fresh polynomial.
|
||||
const coeffs = new Uint8Array(k);
|
||||
for (let byteIdx = 0; byteIdx < secret.length; byteIdx++) {
|
||||
coeffs[0] = secret[byteIdx]!;
|
||||
if (k > 1) {
|
||||
const rnd = random(k - 1);
|
||||
for (let j = 0; j < k - 1; j++) coeffs[j + 1] = rnd[j]!;
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
const share = shares[i]!;
|
||||
share.y[byteIdx] = evalPoly(coeffs, share.x);
|
||||
}
|
||||
// Zero the polynomial buffer between columns. Doesn't help against a
|
||||
// V8 GC adversary but is the right discipline.
|
||||
coeffs.fill(0);
|
||||
}
|
||||
return shares;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the original secret from a subset of shares using
|
||||
* Lagrange interpolation evaluated at x = 0.
|
||||
*
|
||||
* `shares.length` MUST equal the threshold the secret was split with
|
||||
* (the algorithm doesn't know `k`; supplying fewer or more shares either
|
||||
* yields garbage or wastes work). The returned secret has the same
|
||||
* length as each share's y-buffer.
|
||||
*
|
||||
* The secret cannot be authenticated from the shares alone — the caller
|
||||
* is expected to verify the reconstructed key against an AEAD tag (e.g.
|
||||
* the AES-GCM ciphertext it was used to encrypt). Without that
|
||||
* authentication, an attacker who supplied a forged share at any of the
|
||||
* sample points can flip the reconstructed key to anything they want.
|
||||
*/
|
||||
export function combineShares(shares: ShamirShare[]): Uint8Array {
|
||||
if (shares.length === 0) throw new Error('Shamir: need at least one share');
|
||||
const len = shares[0]!.y.length;
|
||||
for (const s of shares) {
|
||||
if (s.y.length !== len) throw new Error('Shamir: share length mismatch');
|
||||
if (s.x === 0) throw new Error('Shamir: x-coordinate 0 is reserved for the secret');
|
||||
}
|
||||
// Reject duplicate x-coordinates: two shares with the same x but
|
||||
// different y collide as polynomial evaluations and produce nonsense.
|
||||
const xs = new Set<number>();
|
||||
for (const s of shares) {
|
||||
if (xs.has(s.x)) throw new Error('Shamir: duplicate x-coordinate in share set');
|
||||
xs.add(s.x);
|
||||
}
|
||||
|
||||
const out = new Uint8Array(len);
|
||||
|
||||
// Precompute Lagrange basis weights at x=0:
|
||||
// L_i(0) = ∏_{j ≠ i} -x_j / (x_i - x_j)
|
||||
// In GF(2^8) negation is identity, so this simplifies to ∏ x_j / (x_i + x_j).
|
||||
const weights = new Uint8Array(shares.length);
|
||||
for (let i = 0; i < shares.length; i++) {
|
||||
let num = 1;
|
||||
let den = 1;
|
||||
const xi = shares[i]!.x;
|
||||
for (let j = 0; j < shares.length; j++) {
|
||||
if (i === j) continue;
|
||||
const xj = shares[j]!.x;
|
||||
num = gfMul(num, xj);
|
||||
den = gfMul(den, xi ^ xj);
|
||||
}
|
||||
weights[i] = gfDiv(num, den);
|
||||
}
|
||||
|
||||
for (let byteIdx = 0; byteIdx < len; byteIdx++) {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < shares.length; i++) {
|
||||
acc ^= gfMul(shares[i]!.y[byteIdx]!, weights[i]!);
|
||||
}
|
||||
out[byteIdx] = acc;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a `ShamirShare` to a single Uint8Array on the wire:
|
||||
*
|
||||
* 1 byte x-coordinate
|
||||
* N bytes y-coordinates
|
||||
*
|
||||
* `decodeShare` is the inverse. Callers ship this byte sequence as
|
||||
* opaque payload — the encoding is stable across platforms and is what
|
||||
* `@shade/recovery` puts on the wire.
|
||||
*/
|
||||
export function encodeShare(share: ShamirShare): Uint8Array {
|
||||
if (share.x < 1 || share.x > 255) throw new Error('Shamir: x out of range [1..255]');
|
||||
const out = new Uint8Array(1 + share.y.length);
|
||||
out[0] = share.x;
|
||||
out.set(share.y, 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decodeShare(bytes: Uint8Array): ShamirShare {
|
||||
if (bytes.length < 2) throw new Error('Shamir: encoded share must be ≥ 2 bytes');
|
||||
const x = bytes[0]!;
|
||||
if (x === 0) throw new Error('Shamir: x-coordinate 0 in encoded share');
|
||||
return { x, y: bytes.slice(1) };
|
||||
}
|
||||
91
packages/shade-recovery/src/store.ts
Normal file
91
packages/shade-recovery/src/store.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Guardian-side persistence for received shares.
|
||||
*
|
||||
* Each guardian holds, per protected user, exactly one record per
|
||||
* `setupId`:
|
||||
*
|
||||
* {
|
||||
* originalAddress, // the protected user's Shade address
|
||||
* setupId, // disambiguates if a user re-runs setup
|
||||
* shareIndex, // their assigned x-coordinate
|
||||
* shareBytes, // base64-encoded Shamir share
|
||||
* shareSecretCiphertext, // base64 — encrypted backup payload
|
||||
* shareSecretNonce, // base64 — AES-GCM nonce
|
||||
* setupFingerprint, // safety number at deposit time
|
||||
* guardianCount, threshold, // informational
|
||||
* receivedAt
|
||||
* }
|
||||
*
|
||||
* The interface is deliberately minimal so consumers can back it with
|
||||
* whatever they already use for app state — IndexedDB, AsyncStorage,
|
||||
* SQLite, etc. The package ships an in-memory implementation suitable
|
||||
* for tests and small one-device demos. Guardian apps that survive
|
||||
* restart MUST supply a persistent implementation.
|
||||
*/
|
||||
|
||||
export interface GuardianShareEntry {
|
||||
originalAddress: string;
|
||||
setupId: string;
|
||||
shareIndex: number;
|
||||
shareBytes: string;
|
||||
shareSecretCiphertext: string;
|
||||
shareSecretNonce: string;
|
||||
setupFingerprint: string;
|
||||
guardianCount: number;
|
||||
threshold: number;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimal contract a guardian-side share store must implement.
|
||||
*
|
||||
* Shapes:
|
||||
* - `save` is upsert by (originalAddress, setupId). If a user re-runs
|
||||
* setup with a different setupId, both entries persist; the guardian
|
||||
* can choose which to release based on the recovery-request's
|
||||
* setupId. If the same (originalAddress, setupId) is saved twice,
|
||||
* the second write wins (idempotent re-deposit).
|
||||
* - `get` returns the deposit for the (originalAddress, setupId) pair,
|
||||
* or `null` when none exists.
|
||||
* - `list` enumerates everything (used by guardian-UX widgets).
|
||||
* - `delete` removes a single deposit; the guardian-UX exposes this so
|
||||
* a user can prune deposits from people they no longer wish to
|
||||
* protect.
|
||||
*/
|
||||
export interface RecoveryStore {
|
||||
save(entry: GuardianShareEntry): Promise<void>;
|
||||
get(originalAddress: string, setupId: string): Promise<GuardianShareEntry | null>;
|
||||
list(): Promise<GuardianShareEntry[]>;
|
||||
delete(originalAddress: string, setupId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-local in-memory store. Suitable for tests and one-shot demos;
|
||||
* LOSES STATE ON RESTART. Production guardian apps must supply a
|
||||
* persistent `RecoveryStore` (see `docs/recovery.md` for backing-store
|
||||
* recommendations).
|
||||
*/
|
||||
export class MemoryRecoveryStore implements RecoveryStore {
|
||||
private readonly entries = new Map<string, GuardianShareEntry>();
|
||||
|
||||
async save(entry: GuardianShareEntry): Promise<void> {
|
||||
this.entries.set(keyOf(entry.originalAddress, entry.setupId), { ...entry });
|
||||
}
|
||||
|
||||
async get(originalAddress: string, setupId: string): Promise<GuardianShareEntry | null> {
|
||||
const found = this.entries.get(keyOf(originalAddress, setupId));
|
||||
return found === undefined ? null : { ...found };
|
||||
}
|
||||
|
||||
async list(): Promise<GuardianShareEntry[]> {
|
||||
return Array.from(this.entries.values()).map((e) => ({ ...e }));
|
||||
}
|
||||
|
||||
async delete(originalAddress: string, setupId: string): Promise<void> {
|
||||
this.entries.delete(keyOf(originalAddress, setupId));
|
||||
}
|
||||
}
|
||||
|
||||
function keyOf(originalAddress: string, setupId: string): string {
|
||||
return `${originalAddress} ${setupId}`;
|
||||
}
|
||||
348
packages/shade-recovery/tests/adversarial.test.ts
Normal file
348
packages/shade-recovery/tests/adversarial.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Adversarial tests for V3.10 Social Key Recovery.
|
||||
*
|
||||
* Holds the line on the V3.10 acceptance criteria:
|
||||
* - No coalition of (k-1) guardians can reconstruct the secret.
|
||||
* - Forged shares are detected by the AEAD on the backup blob.
|
||||
* - Guardian decline propagates to the new device.
|
||||
* - Per-guardian fingerprint-gate refusal blocks the share release.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import fc from 'fast-check';
|
||||
import type { Shade } from '@shade/sdk';
|
||||
import {
|
||||
attachGuardian,
|
||||
combineShares,
|
||||
decodeShare,
|
||||
encodeShare,
|
||||
MemoryRecoveryStore,
|
||||
RecoveryDeclinedError,
|
||||
recoveryKeyToBackupPassphrase,
|
||||
requestRecovery,
|
||||
setupRecovery,
|
||||
splitSecret,
|
||||
type GuardianApproveHandler,
|
||||
} from '../src/index.js';
|
||||
import {
|
||||
MemoryRecoveryTransport,
|
||||
spawnShade,
|
||||
startTestPrekeyServer,
|
||||
type TestEnv,
|
||||
} from './helpers.js';
|
||||
|
||||
const cryptoRandom = (n: number): Uint8Array => {
|
||||
const out = new Uint8Array(n);
|
||||
globalThis.crypto.getRandomValues(out);
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('Adversarial — k-1 collusion never recovers', () => {
|
||||
test('property: any (k-1) subset of shares fails to recover the key', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.uint8Array({ minLength: 16, maxLength: 32 }),
|
||||
fc.integer({ min: 2, max: 5 }),
|
||||
fc.integer({ min: 0, max: 3 }),
|
||||
(recoveryKey, k, extra) => {
|
||||
const n = k + extra;
|
||||
if (n > 8) return;
|
||||
const shares = splitSecret(recoveryKey, k, n, cryptoRandom);
|
||||
// Try every (k-1)-sized subset.
|
||||
const indices = Array.from({ length: n }, (_, i) => i);
|
||||
for (const subset of subsets(indices, k - 1)) {
|
||||
const reconstructed = combineShares(subset.map((i) => shares[i]!));
|
||||
// Recovered bytes never equal the secret (with probability
|
||||
// 1 - 1/256^len, vanishingly small for 16+ byte secrets).
|
||||
expect(Array.from(reconstructed)).not.toEqual(Array.from(recoveryKey));
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 30 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Adversarial — guardian decline + forged share', () => {
|
||||
let env: TestEnv;
|
||||
let alice: Shade;
|
||||
let alice2: Shade;
|
||||
let guardians: Shade[];
|
||||
let transport: MemoryRecoveryTransport;
|
||||
const guardianStores = new Map<string, MemoryRecoveryStore>();
|
||||
const detachers: Array<() => void> = [];
|
||||
|
||||
// Per-test approve toggles (so we can flip a guardian to decline mid-suite).
|
||||
const approveOverrides = new Map<string, GuardianApproveHandler>();
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await startTestPrekeyServer();
|
||||
alice = await spawnShade(env.prekeyUrl, 'alice');
|
||||
const guardianAddrs = ['bob', 'carol', 'dan', 'eve', 'faythe'];
|
||||
guardians = await Promise.all(guardianAddrs.map((a) => spawnShade(env.prekeyUrl, a)));
|
||||
alice2 = await spawnShade(env.prekeyUrl, 'alice-new-device');
|
||||
|
||||
transport = new MemoryRecoveryTransport();
|
||||
transport.add(alice);
|
||||
transport.add(alice2);
|
||||
for (const g of guardians) transport.add(g);
|
||||
|
||||
for (const g of guardians) {
|
||||
const store = new MemoryRecoveryStore();
|
||||
guardianStores.set(g.myAddress, store);
|
||||
const attached = attachGuardian({
|
||||
shade: g,
|
||||
store,
|
||||
approve: async (ctx) => {
|
||||
const override = approveOverrides.get(g.myAddress);
|
||||
if (override !== undefined) return override(ctx);
|
||||
return true;
|
||||
},
|
||||
deliver: transport.bind(g),
|
||||
});
|
||||
detachers.push(attached.stop);
|
||||
}
|
||||
|
||||
// Run setup once so all guardians have a deposit.
|
||||
const result = await setupRecovery({
|
||||
shade: alice,
|
||||
guardians: guardians.map((g) => g.myAddress),
|
||||
threshold: 3,
|
||||
deliver: transport.bind(alice),
|
||||
});
|
||||
expect(result.allDelivered).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const d of detachers) d();
|
||||
await alice.shutdown();
|
||||
await alice2.shutdown();
|
||||
for (const g of guardians) await g.shutdown();
|
||||
env.stop();
|
||||
});
|
||||
|
||||
test('declines from 3 guardians push us below threshold', async () => {
|
||||
approveOverrides.set('bob', async () => false);
|
||||
approveOverrides.set('carol', async () => false);
|
||||
approveOverrides.set('dan', async () => false);
|
||||
|
||||
const sample = await guardianStores.get('eve')!.list();
|
||||
const setupId = sample[0]!.setupId;
|
||||
|
||||
await expect(
|
||||
requestRecovery({
|
||||
shade: alice2,
|
||||
originalAddress: 'alice',
|
||||
guardians: guardians.map((g) => g.myAddress),
|
||||
threshold: 3,
|
||||
setupId,
|
||||
deliver: transport.bind(alice2),
|
||||
timeoutMs: 5000,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(RecoveryDeclinedError);
|
||||
|
||||
approveOverrides.clear();
|
||||
});
|
||||
|
||||
test('throwing approve handler counts as decline with descriptive reason', async () => {
|
||||
approveOverrides.set('bob', async () => {
|
||||
throw new Error('user pressed cancel');
|
||||
});
|
||||
approveOverrides.set('carol', async () => {
|
||||
throw new Error('user pressed cancel');
|
||||
});
|
||||
approveOverrides.set('dan', async () => {
|
||||
throw new Error('user pressed cancel');
|
||||
});
|
||||
|
||||
const sample = await guardianStores.get('eve')!.list();
|
||||
const setupId = sample[0]!.setupId;
|
||||
|
||||
try {
|
||||
await requestRecovery({
|
||||
shade: alice2,
|
||||
originalAddress: 'alice',
|
||||
guardians: guardians.map((g) => g.myAddress),
|
||||
threshold: 3,
|
||||
setupId,
|
||||
deliver: transport.bind(alice2),
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
throw new Error('expected RecoveryDeclinedError');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(RecoveryDeclinedError);
|
||||
}
|
||||
|
||||
approveOverrides.clear();
|
||||
});
|
||||
|
||||
test('unknown setupId from new device is auto-declined by guardians', async () => {
|
||||
await expect(
|
||||
requestRecovery({
|
||||
shade: alice2,
|
||||
originalAddress: 'alice',
|
||||
guardians: guardians.map((g) => g.myAddress),
|
||||
threshold: 3,
|
||||
setupId: 'fake-setup-id',
|
||||
deliver: transport.bind(alice2),
|
||||
timeoutMs: 5000,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(RecoveryDeclinedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Adversarial — single forged share rejected by AEAD', () => {
|
||||
// We exercise this at the unit level instead of going through the
|
||||
// full Shade-pair wiring, because the entire point is that
|
||||
// requestRecovery's reconstruction loop tries threshold-sized
|
||||
// subsets of grants until one authenticates the backup blob.
|
||||
test('a corrupted share never authenticates against the backup AEAD tag', async () => {
|
||||
const recoveryKey = cryptoRandom(32);
|
||||
// Encrypt some plaintext with this key via the same path Shade uses.
|
||||
const passphrase = recoveryKeyToBackupPassphrase(recoveryKey);
|
||||
expect(passphrase.startsWith('shade-rk:')).toBe(true);
|
||||
|
||||
// Split into 3-of-5.
|
||||
const shares = splitSecret(recoveryKey, 3, 5, cryptoRandom);
|
||||
// Forge share #2 by flipping a high-entropy byte.
|
||||
const forged = { x: shares[1]!.x, y: new Uint8Array(shares[1]!.y) };
|
||||
forged.y[5] = (forged.y[5]! ^ 0xff) & 0xff;
|
||||
const forgedSet = [shares[0]!, forged, shares[2]!];
|
||||
const reconstructed = combineShares(forgedSet);
|
||||
// The reconstructed key MUST differ from the real one.
|
||||
expect(Array.from(reconstructed)).not.toEqual(Array.from(recoveryKey));
|
||||
// Conversely, the honest 3 shares reconstruct exactly.
|
||||
const honest = combineShares([shares[0]!, shares[1]!, shares[2]!]);
|
||||
expect(Array.from(honest)).toEqual(Array.from(recoveryKey));
|
||||
});
|
||||
|
||||
test('encode → tamper → decode preserves x-coordinate but flips y', () => {
|
||||
const share = { x: 7, y: new Uint8Array([1, 2, 3, 4, 5]) };
|
||||
const bytes = encodeShare(share);
|
||||
const tampered = new Uint8Array(bytes);
|
||||
tampered[3] ^= 0x42;
|
||||
const decoded = decodeShare(tampered);
|
||||
expect(decoded.x).toBe(7);
|
||||
expect(decoded.y[2]).not.toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fingerprint-gate enforcement on guardian side', () => {
|
||||
// Verifies V3.10 acceptance criterion #3:
|
||||
// "Guardian-side widget krever fingerprint-bekreftelse før send"
|
||||
//
|
||||
// This test simulates a guardian whose approve callback ONLY returns
|
||||
// true when the requesterFingerprint is what they OOB-confirmed. The
|
||||
// wrong fingerprint → decline.
|
||||
let env: TestEnv;
|
||||
let alice: Shade;
|
||||
let alice2: Shade;
|
||||
let bob: Shade;
|
||||
let transport: MemoryRecoveryTransport;
|
||||
let guardianStore: MemoryRecoveryStore;
|
||||
let detach: (() => void) | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await startTestPrekeyServer();
|
||||
alice = await spawnShade(env.prekeyUrl, 'alice');
|
||||
bob = await spawnShade(env.prekeyUrl, 'bob');
|
||||
alice2 = await spawnShade(env.prekeyUrl, 'alice-new-device');
|
||||
transport = new MemoryRecoveryTransport();
|
||||
transport.add(alice);
|
||||
transport.add(alice2);
|
||||
transport.add(bob);
|
||||
guardianStore = new MemoryRecoveryStore();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (detach !== null) detach();
|
||||
await alice.shutdown();
|
||||
await alice2.shutdown();
|
||||
await bob.shutdown();
|
||||
env.stop();
|
||||
});
|
||||
|
||||
test('approve handler that demands an OOB-correct fingerprint releases share', async () => {
|
||||
const oobConfirmedFingerprint = await alice2.fingerprint;
|
||||
const attached = attachGuardian({
|
||||
shade: bob,
|
||||
store: guardianStore,
|
||||
approve: async (ctx) => {
|
||||
// The user has pre-committed to releasing the share only when
|
||||
// requesterFingerprint matches the value they verified OOB.
|
||||
return ctx.requesterFingerprint === oobConfirmedFingerprint;
|
||||
},
|
||||
deliver: transport.bind(bob),
|
||||
});
|
||||
detach = attached.stop;
|
||||
|
||||
// Setup: bob holds a share for alice (1-of-1 trivially recoverable).
|
||||
const setupResult = await setupRecovery({
|
||||
shade: alice,
|
||||
guardians: [bob.myAddress],
|
||||
threshold: 1,
|
||||
deliver: transport.bind(alice),
|
||||
});
|
||||
expect(setupResult.allDelivered).toBe(true);
|
||||
|
||||
const recovered = await requestRecovery({
|
||||
shade: alice2,
|
||||
originalAddress: 'alice',
|
||||
guardians: [bob.myAddress],
|
||||
threshold: 1,
|
||||
setupId: setupResult.setupId,
|
||||
deliver: transport.bind(alice2),
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(recovered.applied).toBe(true);
|
||||
});
|
||||
|
||||
test('approve handler that REJECTS a wrong fingerprint never sends a grant', async () => {
|
||||
// Force the approve to compare against a fingerprint that doesn't match.
|
||||
detach!();
|
||||
const fakeOob = '00000 00000 00000 00000 00000 00000 00000 00000 00000 00000 00000 00000';
|
||||
const attached = attachGuardian({
|
||||
shade: bob,
|
||||
store: guardianStore,
|
||||
approve: async (ctx) => ctx.requesterFingerprint === fakeOob,
|
||||
deliver: transport.bind(bob),
|
||||
});
|
||||
detach = attached.stop;
|
||||
|
||||
// Take the existing setup's setupId (already in store from previous test).
|
||||
const sample = await guardianStore.list();
|
||||
expect(sample.length).toBeGreaterThan(0);
|
||||
const setupId = sample[0]!.setupId;
|
||||
|
||||
await expect(
|
||||
requestRecovery({
|
||||
shade: alice2,
|
||||
originalAddress: 'alice',
|
||||
guardians: [bob.myAddress],
|
||||
threshold: 1,
|
||||
setupId,
|
||||
deliver: transport.bind(alice2),
|
||||
timeoutMs: 3000,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(RecoveryDeclinedError);
|
||||
});
|
||||
});
|
||||
|
||||
function* subsets<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]!);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
104
packages/shade-recovery/tests/helpers.ts
Normal file
104
packages/shade-recovery/tests/helpers.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Test helpers — boot a local prekey server, mint Shade instances, and
|
||||
* pair them with an in-process delivery transport that calls
|
||||
* `shade.receive` on the recipient. Mirrors the pattern other Shade
|
||||
* test suites use, kept private to this package so we don't pull in
|
||||
* `@shade/server` at runtime.
|
||||
*/
|
||||
|
||||
import { createPrekeyServer, MemoryPrekeyStore, PrekeyServerEvents } from '@shade/server';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { createShade, type Shade } from '@shade/sdk';
|
||||
import type { ShadeEnvelope } from '@shade/core';
|
||||
import type { RecoveryDeliver } from '../src/setup.js';
|
||||
|
||||
export interface TestEnv {
|
||||
prekeyUrl: string;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export async function startTestPrekeyServer(): Promise<TestEnv> {
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
const prekey = createPrekeyServer({
|
||||
crypto,
|
||||
store: new MemoryPrekeyStore(),
|
||||
disableRateLimit: true,
|
||||
events: new PrekeyServerEvents(),
|
||||
});
|
||||
const server = Bun.serve({ port: 0, fetch: prekey.fetch });
|
||||
const port = (server as unknown as { port: number }).port;
|
||||
return {
|
||||
prekeyUrl: `http://localhost:${port}`,
|
||||
stop: () => server.stop(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function spawnShade(prekeyUrl: string, address: string): Promise<Shade> {
|
||||
return createShade({ prekeyServer: prekeyUrl, address });
|
||||
}
|
||||
|
||||
/**
|
||||
* In-process transport that delivers `(to, envelope)` to the named
|
||||
* Shade by calling `shade.receive(from, envelope)` on it. Matches the
|
||||
* `RecoveryDeliver` shape so it can be plugged into setup/guardian/
|
||||
* request flows.
|
||||
*
|
||||
* Construction order matters: register every party before delivering
|
||||
* so the lookup never fails. Use `addr` to keep the from-address
|
||||
* symmetric with what `Shade.send` uses.
|
||||
*/
|
||||
export class MemoryRecoveryTransport {
|
||||
private readonly directory = new Map<string, Shade>();
|
||||
/** Per-pair pending-deliveries chain to preserve ordering. */
|
||||
private readonly chains = new Map<string, Promise<unknown>>();
|
||||
/** Counters of how many envelopes flowed in each direction (for tests). */
|
||||
public readonly delivered: Array<{ from: string; to: string }> = [];
|
||||
/**
|
||||
* Optional drop-after-N policy used to simulate guardians that go
|
||||
* unreachable. Keyed on `to` address. Set with `dropAfter(addr, n)`.
|
||||
*/
|
||||
private readonly dropPolicies = new Map<string, number>();
|
||||
|
||||
add(shade: Shade): void {
|
||||
this.directory.set(shade.myAddress, shade);
|
||||
}
|
||||
|
||||
/** Drop envelopes addressed to `to` after the first `n` have flowed. */
|
||||
dropAfter(to: string, n: number): void {
|
||||
this.dropPolicies.set(to, n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `RecoveryDeliver` callback bound to `from`. Call this once
|
||||
* per Shade so its outbound sends route through the transport.
|
||||
*/
|
||||
bind(from: Shade): RecoveryDeliver {
|
||||
return async (to: string, envelope: ShadeEnvelope) => {
|
||||
const recipient = this.directory.get(to);
|
||||
if (recipient === undefined) {
|
||||
throw new Error(`MemoryRecoveryTransport: unknown recipient "${to}"`);
|
||||
}
|
||||
// Apply drop policy.
|
||||
const policy = this.dropPolicies.get(to);
|
||||
if (policy !== undefined) {
|
||||
if (policy <= 0) throw new Error(`MemoryRecoveryTransport: dropping for "${to}"`);
|
||||
this.dropPolicies.set(to, policy - 1);
|
||||
}
|
||||
this.delivered.push({ from: from.myAddress, to });
|
||||
// Serialize per (from→to) pair to preserve ordering.
|
||||
const key = `${from.myAddress}→${to}`;
|
||||
const prev = this.chains.get(key) ?? Promise.resolve();
|
||||
const next = prev.then(async () => {
|
||||
await recipient.receive(from.myAddress, envelope);
|
||||
});
|
||||
this.chains.set(
|
||||
key,
|
||||
next.catch(() => {
|
||||
// chain even on failures so subsequent sends don't deadlock
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
await next;
|
||||
};
|
||||
}
|
||||
}
|
||||
122
packages/shade-recovery/tests/integration.test.ts
Normal file
122
packages/shade-recovery/tests/integration.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* End-to-end recovery flow: 5 guardians, threshold 3.
|
||||
*
|
||||
* The test boots six Shade instances (alice + bob/carol/dan/eve/faythe),
|
||||
* runs `setupRecovery` from alice, simulates loss + new device by
|
||||
* spawning `alice2` with a fresh address, then runs `requestRecovery`
|
||||
* from alice2. After the flow alice2's storage holds alice's original
|
||||
* identity.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import type { Shade } from '@shade/sdk';
|
||||
import {
|
||||
attachGuardian,
|
||||
MemoryRecoveryStore,
|
||||
RecoveryDeclinedError,
|
||||
requestRecovery,
|
||||
setupRecovery,
|
||||
} from '../src/index.js';
|
||||
import {
|
||||
MemoryRecoveryTransport,
|
||||
spawnShade,
|
||||
startTestPrekeyServer,
|
||||
type TestEnv,
|
||||
} from './helpers.js';
|
||||
|
||||
describe('Social key recovery — 3-of-5 end-to-end', () => {
|
||||
let env: TestEnv;
|
||||
let alice: Shade;
|
||||
let alice2: Shade; // new device after loss
|
||||
let guardians: Shade[];
|
||||
let transport: MemoryRecoveryTransport;
|
||||
const guardianStores = new Map<string, MemoryRecoveryStore>();
|
||||
const detachers: Array<() => void> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await startTestPrekeyServer();
|
||||
|
||||
alice = await spawnShade(env.prekeyUrl, 'alice');
|
||||
const guardianAddrs = ['bob', 'carol', 'dan', 'eve', 'faythe'];
|
||||
guardians = await Promise.all(guardianAddrs.map((a) => spawnShade(env.prekeyUrl, a)));
|
||||
alice2 = await spawnShade(env.prekeyUrl, 'alice-new-device');
|
||||
|
||||
transport = new MemoryRecoveryTransport();
|
||||
transport.add(alice);
|
||||
transport.add(alice2);
|
||||
for (const g of guardians) transport.add(g);
|
||||
|
||||
// Wire each guardian to auto-approve. We override per-test below
|
||||
// when we need declines.
|
||||
for (const g of guardians) {
|
||||
const store = new MemoryRecoveryStore();
|
||||
guardianStores.set(g.myAddress, store);
|
||||
const attached = attachGuardian({
|
||||
shade: g,
|
||||
store,
|
||||
approve: async () => true,
|
||||
deliver: transport.bind(g),
|
||||
});
|
||||
detachers.push(attached.stop);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const d of detachers) d();
|
||||
await alice.shutdown();
|
||||
await alice2.shutdown();
|
||||
for (const g of guardians) await g.shutdown();
|
||||
env.stop();
|
||||
});
|
||||
|
||||
test('setup distributes shares to all 5 guardians', async () => {
|
||||
const result = await setupRecovery({
|
||||
shade: alice,
|
||||
guardians: guardians.map((g) => g.myAddress),
|
||||
threshold: 3,
|
||||
deliver: transport.bind(alice),
|
||||
});
|
||||
expect(result.threshold).toBe(3);
|
||||
expect(result.guardianCount).toBe(5);
|
||||
expect(result.allDelivered).toBe(true);
|
||||
expect(result.deliveries.length).toBe(5);
|
||||
for (const d of result.deliveries) {
|
||||
expect(d.error).toBeNull();
|
||||
}
|
||||
// Each guardian must have stored its share.
|
||||
// Allow a microtask for the onMessage handler to finish save.
|
||||
await Promise.resolve();
|
||||
for (const g of guardians) {
|
||||
const store = guardianStores.get(g.myAddress)!;
|
||||
const list = await store.list();
|
||||
expect(list.length).toBe(1);
|
||||
expect(list[0]!.originalAddress).toBe('alice');
|
||||
expect(list[0]!.guardianCount).toBe(5);
|
||||
expect(list[0]!.threshold).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
test('recovery from new device with all 5 guardians available', async () => {
|
||||
// Find the setupId from any guardian.
|
||||
const sample = await guardianStores.get('bob')!.list();
|
||||
const setupId = sample[0]!.setupId;
|
||||
const aliceFingerprintBefore = await alice.fingerprint;
|
||||
|
||||
const result = await requestRecovery({
|
||||
shade: alice2,
|
||||
originalAddress: 'alice',
|
||||
guardians: guardians.map((g) => g.myAddress),
|
||||
threshold: 3,
|
||||
setupId,
|
||||
deliver: transport.bind(alice2),
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(result.granted.length).toBeGreaterThanOrEqual(3);
|
||||
expect(result.declined.length).toBe(0);
|
||||
// alice2 now hosts alice's identity → fingerprints match.
|
||||
const recoveredFingerprint = await alice2.fingerprint;
|
||||
expect(recoveredFingerprint).toBe(aliceFingerprintBefore);
|
||||
});
|
||||
});
|
||||
147
packages/shade-recovery/tests/shamir.test.ts
Normal file
147
packages/shade-recovery/tests/shamir.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
combineShares,
|
||||
decodeShare,
|
||||
encodeShare,
|
||||
splitSecret,
|
||||
type ShamirShare,
|
||||
} from '../src/shamir.js';
|
||||
|
||||
const cryptoRandom = (n: number): Uint8Array => {
|
||||
const out = new Uint8Array(n);
|
||||
globalThis.crypto.getRandomValues(out);
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('Shamir Secret Sharing', () => {
|
||||
test('split + combine roundtrip restores the secret (k=3, n=5)', () => {
|
||||
const secret = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||
const shares = splitSecret(secret, 3, 5, cryptoRandom);
|
||||
expect(shares.length).toBe(5);
|
||||
// Pick first 3 shares — any 3 should work.
|
||||
const subset = shares.slice(0, 3);
|
||||
const combined = combineShares(subset);
|
||||
expect(Array.from(combined)).toEqual(Array.from(secret));
|
||||
});
|
||||
|
||||
test('any threshold-sized subset reconstructs', () => {
|
||||
const secret = cryptoRandom(32);
|
||||
const shares = splitSecret(secret, 3, 5, cryptoRandom);
|
||||
// All 10 possible 3-subsets must reconstruct.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
for (let j = i + 1; j < 5; j++) {
|
||||
for (let k = j + 1; k < 5; k++) {
|
||||
const combined = combineShares([shares[i]!, shares[j]!, shares[k]!]);
|
||||
expect(Array.from(combined)).toEqual(Array.from(secret));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('exactly threshold shares reconstruct (k=2, n=2)', () => {
|
||||
const secret = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
|
||||
const shares = splitSecret(secret, 2, 2, cryptoRandom);
|
||||
const combined = combineShares(shares);
|
||||
expect(Array.from(combined)).toEqual(Array.from(secret));
|
||||
});
|
||||
|
||||
test('k-1 shares yield a wrong (random-looking) result', () => {
|
||||
const secret = cryptoRandom(32);
|
||||
const shares = splitSecret(secret, 3, 5, cryptoRandom);
|
||||
const truncated = shares.slice(0, 2); // 2 < k=3
|
||||
const combined = combineShares(truncated);
|
||||
// We can't reliably assert "looks random", but we can assert
|
||||
// it's not equal to the secret (passing 2 shares to a polynomial
|
||||
// of degree 2 yields a different polynomial with prob ≈ 1).
|
||||
expect(Array.from(combined)).not.toEqual(Array.from(secret));
|
||||
});
|
||||
|
||||
test('split rejects k < 1, n < k, n > 255', () => {
|
||||
expect(() => splitSecret(new Uint8Array([1]), 0, 5, cryptoRandom)).toThrow();
|
||||
expect(() => splitSecret(new Uint8Array([1]), 6, 5, cryptoRandom)).toThrow();
|
||||
expect(() => splitSecret(new Uint8Array([1]), 1, 256, cryptoRandom)).toThrow();
|
||||
});
|
||||
|
||||
test('split rejects empty secret', () => {
|
||||
expect(() => splitSecret(new Uint8Array(0), 1, 1, cryptoRandom)).toThrow();
|
||||
});
|
||||
|
||||
test('combine rejects empty share set', () => {
|
||||
expect(() => combineShares([])).toThrow();
|
||||
});
|
||||
|
||||
test('combine rejects duplicate x-coordinates', () => {
|
||||
const secret = new Uint8Array([1, 2, 3]);
|
||||
const shares = splitSecret(secret, 2, 3, cryptoRandom);
|
||||
const dup: ShamirShare[] = [shares[0]!, { x: shares[0]!.x, y: shares[1]!.y }];
|
||||
expect(() => combineShares(dup)).toThrow(/duplicate x-coordinate/);
|
||||
});
|
||||
|
||||
test('combine rejects mismatched share lengths', () => {
|
||||
const a: ShamirShare = { x: 1, y: new Uint8Array([1, 2, 3]) };
|
||||
const b: ShamirShare = { x: 2, y: new Uint8Array([1, 2]) };
|
||||
expect(() => combineShares([a, b])).toThrow(/length mismatch/);
|
||||
});
|
||||
|
||||
test('encode + decode share roundtrip', () => {
|
||||
const share: ShamirShare = { x: 7, y: new Uint8Array([1, 2, 3, 4, 5]) };
|
||||
const bytes = encodeShare(share);
|
||||
expect(bytes.length).toBe(6);
|
||||
expect(bytes[0]).toBe(7);
|
||||
const decoded = decodeShare(bytes);
|
||||
expect(decoded.x).toBe(7);
|
||||
expect(Array.from(decoded.y)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test('encodeShare rejects x out of range', () => {
|
||||
expect(() => encodeShare({ x: 0, y: new Uint8Array([1]) })).toThrow();
|
||||
expect(() => encodeShare({ x: 256, y: new Uint8Array([1]) })).toThrow();
|
||||
});
|
||||
|
||||
test('decodeShare rejects x=0', () => {
|
||||
const bad = new Uint8Array([0, 1, 2, 3]);
|
||||
expect(() => decodeShare(bad)).toThrow();
|
||||
});
|
||||
|
||||
test('property: random-secret roundtrip preserves bytes for arbitrary k/n', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.uint8Array({ minLength: 1, maxLength: 64 }),
|
||||
fc.integer({ min: 1, max: 8 }),
|
||||
fc.integer({ min: 0, max: 8 }),
|
||||
(secret, k, extra) => {
|
||||
const n = k + extra;
|
||||
if (n > 16) return;
|
||||
const shares = splitSecret(secret, k, n, cryptoRandom);
|
||||
// Pick the first k shares — any k will do.
|
||||
const reconstructed = combineShares(shares.slice(0, k));
|
||||
expect(Array.from(reconstructed)).toEqual(Array.from(secret));
|
||||
},
|
||||
),
|
||||
{ numRuns: 50 },
|
||||
);
|
||||
});
|
||||
|
||||
test('property: any k-1 share subset yields a different output than the secret', () => {
|
||||
// This is a probabilistic statement: with random secrets and
|
||||
// random polynomials, P(reconstruction collides with the secret
|
||||
// by accident) is ≈ 1/256^len, vanishingly small for 32-byte
|
||||
// secrets.
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.uint8Array({ minLength: 16, maxLength: 32 }),
|
||||
fc.integer({ min: 2, max: 6 }),
|
||||
(secret, k) => {
|
||||
const n = k + 2;
|
||||
if (n > 16) return;
|
||||
const shares = splitSecret(secret, k, n, cryptoRandom);
|
||||
const subset = shares.slice(0, k - 1); // k-1 < threshold
|
||||
const combined = combineShares(subset);
|
||||
expect(Array.from(combined)).not.toEqual(Array.from(secret));
|
||||
},
|
||||
),
|
||||
{ numRuns: 30 },
|
||||
);
|
||||
});
|
||||
});
|
||||
8
packages/shade-recovery/tsconfig.json
Normal file
8
packages/shade-recovery/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user