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:
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user