/** * EncryptID Linked Wallet Store * * Client-side encrypted store for externally linked wallets (MetaMask, * Rainbow, pre-existing Safes). Follows the same AES-256-GCM pattern * as WalletStore (wallet-store.ts). * * Privacy model: Server stores only encrypted blobs + keccak256(address) * for dedup. All cleartext data is decrypted client-side only. */ import { encryptData, decryptDataAsString, type EncryptedData } from './key-derivation'; import { bufferToBase64url, base64urlToBuffer } from './webauthn'; // ============================================================================ // TYPES // ============================================================================ export interface LinkedWalletEntry { id: string; address: string; type: 'eoa' | 'safe'; chainIds: number[]; label: string; isDefault: boolean; addedAt: number; verifiedAt: number; providerRdns?: string; providerName?: string; safeInfo?: { threshold: number; ownerCount: number; isEncryptIdOwner: boolean; }; } interface StoredLinkedWalletData { version: 1; wallets: LinkedWalletEntry[]; } interface PersistedBlob { c: string; iv: string; } // ============================================================================ // CONSTANTS // ============================================================================ const STORAGE_KEY = 'encryptid_linked_wallets'; // ============================================================================ // LINKED WALLET STORE // ============================================================================ export class LinkedWalletStore { private encryptionKey: CryptoKey; private cache: LinkedWalletEntry[] | null = null; constructor(encryptionKey: CryptoKey) { this.encryptionKey = encryptionKey; } async list(): Promise { if (this.cache) return [...this.cache]; const wallets = await this.load(); this.cache = wallets; return [...wallets]; } async getDefault(): Promise { const wallets = await this.list(); return wallets.find(w => w.isDefault) || wallets[0] || null; } async get(address: string): Promise { const wallets = await this.list(); return wallets.find( w => w.address.toLowerCase() === address.toLowerCase(), ) || null; } async getById(id: string): Promise { const wallets = await this.list(); return wallets.find(w => w.id === id) || null; } async add(entry: Omit & { isDefault?: boolean; }): Promise { const wallets = await this.list(); // Dedup by address const existing = wallets.find( w => w.address.toLowerCase() === entry.address.toLowerCase(), ); if (existing) { existing.label = entry.label; existing.chainIds = entry.chainIds; existing.providerRdns = entry.providerRdns; existing.providerName = entry.providerName; existing.verifiedAt = entry.verifiedAt; if (entry.safeInfo) existing.safeInfo = entry.safeInfo; if (entry.isDefault) { wallets.forEach(w => w.isDefault = false); existing.isDefault = true; } await this.save(wallets); return { ...existing }; } const wallet: LinkedWalletEntry = { id: crypto.randomUUID(), address: entry.address, type: entry.type, chainIds: entry.chainIds, label: entry.label, isDefault: entry.isDefault ?? wallets.length === 0, addedAt: Date.now(), verifiedAt: entry.verifiedAt, providerRdns: entry.providerRdns, providerName: entry.providerName, safeInfo: entry.safeInfo, }; if (wallet.isDefault) { wallets.forEach(w => w.isDefault = false); } wallets.push(wallet); await this.save(wallets); return { ...wallet }; } async update(id: string, updates: Partial>): Promise { const wallets = await this.list(); const wallet = wallets.find(w => w.id === id); if (!wallet) return null; if (updates.label !== undefined) wallet.label = updates.label; if (updates.chainIds !== undefined) wallet.chainIds = updates.chainIds; if (updates.safeInfo !== undefined) wallet.safeInfo = updates.safeInfo; if (updates.isDefault) { wallets.forEach(w => w.isDefault = false); wallet.isDefault = true; } await this.save(wallets); return { ...wallet }; } async remove(id: string): Promise { const wallets = await this.list(); const idx = wallets.findIndex(w => w.id === id); if (idx === -1) return false; const wasDefault = wallets[idx].isDefault; wallets.splice(idx, 1); if (wasDefault && wallets.length > 0) { wallets[0].isDefault = true; } await this.save(wallets); return true; } async clear(): Promise { this.cache = []; try { localStorage.removeItem(STORAGE_KEY); } catch {} } // ========================================================================== // ENCRYPTION HELPERS // ========================================================================== async encryptEntry(entry: LinkedWalletEntry): Promise<{ ciphertext: string; iv: string }> { const json = JSON.stringify(entry); const encrypted = await encryptData(this.encryptionKey, json); return { ciphertext: bufferToBase64url(encrypted.ciphertext), iv: bufferToBase64url(encrypted.iv.buffer as ArrayBuffer), }; } async decryptEntry(ciphertext: string, iv: string): Promise { const encrypted: EncryptedData = { ciphertext: base64urlToBuffer(ciphertext), iv: new Uint8Array(base64urlToBuffer(iv)), }; const json = await decryptDataAsString(this.encryptionKey, encrypted); return JSON.parse(json); } // ========================================================================== // PRIVATE: Encrypt/Decrypt localStorage // ========================================================================== private async load(): Promise { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return []; const blob: PersistedBlob = JSON.parse(raw); if (!blob.c || !blob.iv) return []; const encrypted: EncryptedData = { ciphertext: base64urlToBuffer(blob.c), iv: new Uint8Array(base64urlToBuffer(blob.iv)), }; const json = await decryptDataAsString(this.encryptionKey, encrypted); const data: StoredLinkedWalletData = JSON.parse(json); if (data.version !== 1) { console.warn('EncryptID LinkedWalletStore: Unknown schema version', data.version); return []; } return data.wallets; } catch (err) { console.warn('EncryptID LinkedWalletStore: Failed to load wallets', err); return []; } } private async save(wallets: LinkedWalletEntry[]): Promise { this.cache = [...wallets]; const data: StoredLinkedWalletData = { version: 1, wallets }; const json = JSON.stringify(data); const encrypted = await encryptData(this.encryptionKey, json); const blob: PersistedBlob = { c: bufferToBase64url(encrypted.ciphertext), iv: bufferToBase64url(encrypted.iv.buffer as ArrayBuffer), }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(blob)); } catch (err) { console.error('EncryptID LinkedWalletStore: Failed to save wallets', err); } } } // ============================================================================ // SINGLETON // ============================================================================ let linkedWalletStoreInstance: LinkedWalletStore | null = null; export function getLinkedWalletStore(encryptionKey: CryptoKey): LinkedWalletStore { if (!linkedWalletStoreInstance) { linkedWalletStoreInstance = new LinkedWalletStore(encryptionKey); } return linkedWalletStoreInstance; } export function resetLinkedWalletStore(): void { linkedWalletStoreInstance = null; } // ============================================================================ // UTILITIES // ============================================================================ export async function hashAddress(address: string, userId?: string): Promise { // Salt with userId to prevent cross-user address correlation const normalized = (userId ? userId + ':' : '') + address.toLowerCase(); const encoded = new TextEncoder().encode(normalized); const hash = await crypto.subtle.digest('SHA-256', encoded); return bufferToBase64url(hash); }