/** * 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 { 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 { 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): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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: '', }; } }