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

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:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View 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)}`;
}

View 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';
}
}

View 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}`);
}

View 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';

View 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}]`,
);
}
}

View 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');
}
}

View 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');
}
}

View 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) };
}

View 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}`;
}