rspace-online/src/encryptid/recovery.ts

770 lines
22 KiB
TypeScript

/**
* EncryptID Social Recovery Module
*
* Implements guardian-based account recovery with NO SEED PHRASES.
* This is Layer 4 of the EncryptID architecture.
*/
import { bufferToBase64url, base64urlToBuffer } from './webauthn';
// ============================================================================
// TYPES
// ============================================================================
/**
* Types of guardians that can be added
*/
export enum GuardianType {
/** Another passkey owned by the user (e.g., YubiKey backup) */
SECONDARY_PASSKEY = 'secondary_passkey',
/** A trusted contact with their own EncryptID */
TRUSTED_CONTACT = 'trusted_contact',
/** A hardware security key stored offline */
HARDWARE_KEY = 'hardware_key',
/** An institutional guardian (service provider) */
INSTITUTIONAL = 'institutional',
/** Time-delayed self-recovery (requires waiting period) */
TIME_DELAYED_SELF = 'time_delayed_self',
}
/**
* Guardian configuration
*/
export interface Guardian {
id: string;
type: GuardianType;
name: string;
weight: number; // Contribution to threshold (usually 1)
// Type-specific data
credentialId?: string; // For SECONDARY_PASSKEY
contactDID?: string; // For TRUSTED_CONTACT
contactEmail?: string; // For notification
serviceUrl?: string; // For INSTITUTIONAL
delaySeconds?: number; // For TIME_DELAYED_SELF
// Metadata
addedAt: number;
lastVerified?: number;
}
/**
* Recovery configuration for an account
*/
export interface RecoveryConfig {
/** Required weight to recover (e.g., 3 for 3-of-5) */
threshold: number;
/** Time-lock delay in seconds before recovery completes */
delaySeconds: number;
/** List of guardians */
guardians: Guardian[];
/** Hash of guardian addresses (for privacy) */
guardianListHash: string;
/** When config was last updated */
updatedAt: number;
}
/**
* Active recovery request
*/
export interface RecoveryRequest {
id: string;
accountDID: string;
newCredentialId: string;
initiatedAt: number;
completesAt: number;
status: 'pending' | 'approved' | 'cancelled' | 'completed';
/** Guardians who have approved */
approvals: {
guardianId: string;
approvedAt: number;
signature: string;
}[];
/** Total weight of approvals */
approvalWeight: number;
}
// ============================================================================
// GUARDIAN MANAGEMENT
// ============================================================================
/**
* EncryptID Recovery Manager
*
* Handles guardian configuration and recovery flows.
*/
export class RecoveryManager {
private config: RecoveryConfig | null = null;
private activeRequest: RecoveryRequest | null = null;
private loaded = false;
constructor() {
this.loadLocalSettings();
}
/** Get the auth token for server API calls */
private getAuthToken(): string | null {
try {
return localStorage.getItem('encryptid_token');
} catch {
return null;
}
}
/** Resolve EncryptID API base URL */
private getApiBase(): string {
// When running on the encryptid domain, use relative paths
if (typeof location !== 'undefined' && location.hostname.includes('auth.')) {
return '';
}
// From rspace canvas, reach encryptid via its public URL
return 'https://auth.rspace.online';
}
/**
* Initialize recovery with default configuration.
* Fetches existing guardians from server if authenticated.
*/
async initializeRecovery(threshold: number = 3): Promise<RecoveryConfig> {
this.config = {
threshold,
delaySeconds: 48 * 60 * 60, // 48 hours
guardians: [],
guardianListHash: '',
updatedAt: Date.now(),
};
// Try to load existing guardians from server
await this.syncFromServer();
await this.saveLocalSettings();
return this.config;
}
/**
* Ensure config is loaded (call before operations that need config)
*/
async ensureLoaded(): Promise<void> {
if (this.loaded) return;
await this.syncFromServer();
this.loaded = true;
}
/**
* Add a guardian — persists to server API (which sends invite email).
* Falls back to local-only if not authenticated.
*/
async addGuardian(guardian: Omit<Guardian, 'id' | 'addedAt'>): Promise<Guardian> {
if (!this.config) {
throw new Error('Recovery not initialized');
}
if (this.config.guardians.length >= 3) {
throw new Error('Maximum of 3 guardians allowed. Remove one first.');
}
const token = this.getAuthToken();
let serverId: string | undefined;
// POST to server — creates DB row + sends invite email
if (token) {
const res = await fetch(`${this.getApiBase()}/api/guardians`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
name: guardian.name,
email: guardian.contactEmail || undefined,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Server error' }));
throw new Error(err.error || `Server error: ${res.status}`);
}
const data = await res.json();
serverId = data.guardian?.id;
console.log('EncryptID: Guardian added via server', {
id: serverId,
inviteUrl: data.inviteUrl,
});
}
const newGuardian: Guardian = {
...guardian,
id: serverId || bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
addedAt: Date.now(),
};
this.config.guardians.push(newGuardian);
this.config.guardianListHash = await this.hashGuardianList();
this.config.updatedAt = Date.now();
await this.saveLocalSettings();
console.log('EncryptID: Guardian added', {
type: GuardianType[guardian.type],
name: guardian.name,
});
return newGuardian;
}
/**
* Remove a guardian — deletes from server API and local config.
*/
async removeGuardian(guardianId: string): Promise<void> {
if (!this.config) {
throw new Error('Recovery not initialized');
}
const index = this.config.guardians.findIndex(g => g.id === guardianId);
if (index === -1) {
throw new Error('Guardian not found');
}
// DELETE from server
const token = this.getAuthToken();
if (token) {
const res = await fetch(`${this.getApiBase()}/api/guardians/${guardianId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok && res.status !== 404) {
const err = await res.json().catch(() => ({ error: 'Server error' }));
throw new Error(err.error || `Server error: ${res.status}`);
}
}
this.config.guardians.splice(index, 1);
this.config.guardianListHash = await this.hashGuardianList();
this.config.updatedAt = Date.now();
await this.saveLocalSettings();
console.log('EncryptID: Guardian removed');
}
/**
* Update recovery threshold
*/
async setThreshold(threshold: number): Promise<void> {
if (!this.config) {
throw new Error('Recovery not initialized');
}
const totalWeight = this.config.guardians.reduce((sum, g) => sum + g.weight, 0);
if (threshold > totalWeight) {
throw new Error('Threshold cannot exceed total guardian weight');
}
if (threshold < 1) {
throw new Error('Threshold must be at least 1');
}
this.config.threshold = threshold;
this.config.updatedAt = Date.now();
await this.saveLocalSettings();
console.log('EncryptID: Threshold updated to', threshold);
}
/**
* Set recovery time-lock delay
*/
async setDelay(delaySeconds: number): Promise<void> {
if (!this.config) {
throw new Error('Recovery not initialized');
}
// Minimum 1 hour, maximum 7 days
if (delaySeconds < 3600 || delaySeconds > 7 * 24 * 3600) {
throw new Error('Delay must be between 1 hour and 7 days');
}
this.config.delaySeconds = delaySeconds;
this.config.updatedAt = Date.now();
await this.saveLocalSettings();
console.log('EncryptID: Delay updated to', delaySeconds, 'seconds');
}
/**
* Get current configuration
*/
getConfig(): RecoveryConfig | null {
return this.config;
}
/**
* Check if recovery is properly configured
*/
isConfigured(): boolean {
if (!this.config) return false;
const totalWeight = this.config.guardians.reduce((sum, g) => sum + g.weight, 0);
return totalWeight >= this.config.threshold;
}
/**
* Verify a guardian is still reachable/valid.
* Checks server for current guardian status (pending/accepted).
*/
async verifyGuardian(guardianId: string): Promise<boolean> {
if (!this.config) {
throw new Error('Recovery not initialized');
}
const guardian = this.config.guardians.find(g => g.id === guardianId);
if (!guardian) {
throw new Error('Guardian not found');
}
const token = this.getAuthToken();
if (token) {
// Fetch current guardian list from server to check status
const res = await fetch(`${this.getApiBase()}/api/guardians`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
const serverGuardian = data.guardians?.find((g: any) => g.id === guardianId);
if (!serverGuardian) {
throw new Error('Guardian not found on server — may have been removed');
}
// Check acceptance status
if (serverGuardian.status === 'accepted') {
guardian.lastVerified = Date.now();
await this.saveLocalSettings();
return true;
}
// Still pending — guardian hasn't accepted invite yet
if (serverGuardian.status === 'pending') {
console.log('EncryptID: Guardian invite still pending', { guardianId });
return false;
}
}
}
// Fallback: mark as verified locally (offline mode)
guardian.lastVerified = Date.now();
await this.saveLocalSettings();
return true;
}
// ==========================================================================
// RECOVERY FLOW
// ==========================================================================
/**
* Initiate account recovery
*
* This starts the recovery process. Guardians must approve,
* and there's a time-lock before recovery completes.
*/
async initiateRecovery(newCredentialId: string): Promise<RecoveryRequest> {
if (!this.config) {
throw new Error('Recovery not configured');
}
if (this.activeRequest && this.activeRequest.status === 'pending') {
throw new Error('Recovery already in progress');
}
const now = Date.now();
this.activeRequest = {
id: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
accountDID: '', // Would be set from current session
newCredentialId,
initiatedAt: now,
completesAt: now + this.config.delaySeconds * 1000,
status: 'pending',
approvals: [],
approvalWeight: 0,
};
// Notify user through all available channels
await this.notifyUser('recovery_initiated', this.activeRequest);
// Notify guardians
await this.notifyGuardians('recovery_requested', this.activeRequest);
console.log('EncryptID: Recovery initiated', {
requestId: this.activeRequest.id,
completesAt: new Date(this.activeRequest.completesAt).toISOString(),
});
return this.activeRequest;
}
/**
* Guardian approves a recovery request
*/
async approveRecovery(
guardianId: string,
signature: string
): Promise<RecoveryRequest> {
if (!this.activeRequest || this.activeRequest.status !== 'pending') {
throw new Error('No pending recovery request');
}
if (!this.config) {
throw new Error('Recovery not configured');
}
const guardian = this.config.guardians.find(g => g.id === guardianId);
if (!guardian) {
throw new Error('Guardian not found');
}
// Check if guardian already approved
if (this.activeRequest.approvals.some(a => a.guardianId === guardianId)) {
throw new Error('Guardian already approved');
}
// Add approval
this.activeRequest.approvals.push({
guardianId,
approvedAt: Date.now(),
signature,
});
this.activeRequest.approvalWeight += guardian.weight;
// Check if threshold reached
if (this.activeRequest.approvalWeight >= this.config.threshold) {
this.activeRequest.status = 'approved';
await this.notifyUser('recovery_approved', this.activeRequest);
}
console.log('EncryptID: Guardian approved recovery', {
guardianId,
weight: guardian.weight,
totalWeight: this.activeRequest.approvalWeight,
threshold: this.config.threshold,
});
return this.activeRequest;
}
/**
* Cancel an active recovery request
*
* User can cancel if they still have access to any valid authenticator.
*/
async cancelRecovery(): Promise<void> {
if (!this.activeRequest || this.activeRequest.status !== 'pending') {
throw new Error('No pending recovery request to cancel');
}
this.activeRequest.status = 'cancelled';
// Notify guardians
await this.notifyGuardians('recovery_cancelled', this.activeRequest);
console.log('EncryptID: Recovery cancelled');
this.activeRequest = null;
}
/**
* Complete the recovery process
*
* Can only be called after time-lock expires and threshold is met.
*/
async completeRecovery(): Promise<void> {
if (!this.activeRequest) {
throw new Error('No recovery request');
}
if (this.activeRequest.status !== 'approved') {
throw new Error('Recovery not approved');
}
if (Date.now() < this.activeRequest.completesAt) {
const remaining = this.activeRequest.completesAt - Date.now();
throw new Error(`Time-lock not expired. ${Math.ceil(remaining / 1000 / 60)} minutes remaining.`);
}
// In production, this would:
// 1. Rotate the account owner to the new credential
// 2. Invalidate old credentials
// 3. Update on-chain state (for AA wallet)
this.activeRequest.status = 'completed';
console.log('EncryptID: Recovery completed successfully');
// Notify user
await this.notifyUser('recovery_completed', this.activeRequest);
this.activeRequest = null;
}
/**
* Get active recovery request
*/
getActiveRequest(): RecoveryRequest | null {
return this.activeRequest;
}
// ==========================================================================
// PRIVATE METHODS
// ==========================================================================
/**
* Hash guardian list for privacy-preserving on-chain storage
*/
private async hashGuardianList(): Promise<string> {
if (!this.config) return '';
// Sort guardian IDs for deterministic hash
const sortedIds = this.config.guardians
.map(g => g.id)
.sort()
.join(',');
const encoder = new TextEncoder();
const hash = await crypto.subtle.digest('SHA-256', encoder.encode(sortedIds));
return bufferToBase64url(hash);
}
/**
* Save local settings (threshold, delay) to localStorage.
* Guardian CRUD goes through the server API — this only persists
* config that the server doesn't track (threshold, delay, type metadata).
*/
private async saveLocalSettings(): Promise<void> {
if (!this.config) return;
try {
localStorage.setItem('encryptid_recovery', JSON.stringify(this.config));
} catch (error) {
console.warn('EncryptID: Failed to save recovery config', error);
}
}
/**
* Load local settings from localStorage (synchronous, for constructor).
* Call syncFromServer() after construction for full server sync.
*/
private loadLocalSettings(): void {
try {
const stored = localStorage.getItem('encryptid_recovery');
if (stored) {
this.config = JSON.parse(stored);
}
} catch (error) {
console.warn('EncryptID: Failed to load recovery config', error);
}
}
/**
* Sync guardian list from server API into local config.
* Maps server guardian model to client Guardian type.
*/
private async syncFromServer(): Promise<void> {
const token = this.getAuthToken();
if (!token) return;
try {
const res = await fetch(`${this.getApiBase()}/api/guardians`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) return;
const data = await res.json();
if (!data.guardians || !Array.isArray(data.guardians)) return;
if (!this.config) {
this.config = {
threshold: data.threshold || 2,
delaySeconds: 48 * 60 * 60,
guardians: [],
guardianListHash: '',
updatedAt: Date.now(),
};
}
// Build a map of existing local guardians for metadata preservation
const localMap = new Map(this.config.guardians.map(g => [g.id, g]));
// Merge server guardians with local type metadata
this.config.guardians = data.guardians.map((sg: any) => {
const local = localMap.get(sg.id);
return {
id: sg.id,
type: local?.type ?? GuardianType.TRUSTED_CONTACT,
name: sg.name,
weight: local?.weight ?? 1,
contactEmail: sg.email || local?.contactEmail,
addedAt: sg.createdAt ? new Date(sg.createdAt).getTime() : (local?.addedAt ?? Date.now()),
lastVerified: sg.status === 'accepted' ? (sg.acceptedAt ? new Date(sg.acceptedAt).getTime() : Date.now()) : local?.lastVerified,
// Preserve type-specific fields from local
credentialId: local?.credentialId,
contactDID: local?.contactDID,
serviceUrl: local?.serviceUrl,
delaySeconds: local?.delaySeconds,
} satisfies Guardian;
});
this.config.guardianListHash = await this.hashGuardianList();
this.config.updatedAt = Date.now();
await this.saveLocalSettings();
console.log('EncryptID: Synced guardians from server', {
count: this.config.guardians.length,
});
} catch (error) {
console.warn('EncryptID: Failed to sync guardians from server', error);
}
}
/**
* Notify user of recovery events via CustomEvent on document.
*/
private async notifyUser(
event: string,
request: RecoveryRequest
): Promise<void> {
console.log('EncryptID: User notification', { event, requestId: request.id });
if (typeof document !== 'undefined') {
document.dispatchEvent(new CustomEvent('encryptid:recovery', {
detail: { event, requestId: request.id, status: request.status },
}));
}
}
/**
* Notify guardians of recovery events via CustomEvent on document.
* Server-side email notifications are handled by the guardian invite flow.
*/
private async notifyGuardians(
event: string,
request: RecoveryRequest
): Promise<void> {
if (!this.config) return;
console.log('EncryptID: Guardian notification', {
event,
guardianCount: this.config.guardians.length,
});
if (typeof document !== 'undefined') {
document.dispatchEvent(new CustomEvent('encryptid:guardian-notification', {
detail: {
event,
requestId: request.id,
guardianIds: this.config.guardians.map(g => g.id),
},
}));
}
}
}
// ============================================================================
// SINGLETON INSTANCE
// ============================================================================
let recoveryManagerInstance: RecoveryManager | null = null;
/**
* Get the global recovery manager instance
*/
export function getRecoveryManager(): RecoveryManager {
if (!recoveryManagerInstance) {
recoveryManagerInstance = new RecoveryManager();
}
return recoveryManagerInstance;
}
// ============================================================================
// GUARDIAN TYPE METADATA
// ============================================================================
/**
* Get display information for guardian types
*/
export function getGuardianTypeInfo(type: GuardianType): {
name: string;
description: string;
icon: string;
setupInstructions: string;
} {
switch (type) {
case GuardianType.SECONDARY_PASSKEY:
return {
name: 'Backup Passkey',
description: 'Another device you own (phone, YubiKey, etc.)',
icon: 'key',
setupInstructions: 'Register a passkey on a second device you control. Store it securely as a backup.',
};
case GuardianType.TRUSTED_CONTACT:
return {
name: 'Trusted Contact',
description: 'A friend or family member with their own EncryptID',
icon: 'user',
setupInstructions: 'Ask a trusted person to create an EncryptID account. They can help recover your account if needed.',
};
case GuardianType.HARDWARE_KEY:
return {
name: 'Hardware Security Key',
description: 'A YubiKey or similar device stored offline',
icon: 'shield',
setupInstructions: 'Register a hardware security key and store it in a safe place (e.g., safe deposit box).',
};
case GuardianType.INSTITUTIONAL:
return {
name: 'Recovery Service',
description: 'A professional recovery service provider',
icon: 'building',
setupInstructions: 'Connect with a trusted recovery service that can help verify your identity.',
};
case GuardianType.TIME_DELAYED_SELF:
return {
name: 'Time-Delayed Self',
description: 'Recover yourself after a waiting period',
icon: 'clock',
setupInstructions: 'Set up a recovery option that requires waiting (e.g., 7 days) before completing.',
};
default:
return {
name: 'Unknown',
description: 'Unknown guardian type',
icon: 'question',
setupInstructions: '',
};
}
}