diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index 017356f..683333e 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -145,23 +145,17 @@ class FolkWalletViewer extends HTMLElement { if (!res.ok) return; const data = await res.json(); - // The server returns encrypted blobs. For the UI, we need the client-side - // decrypted data from LinkedWalletStore. For now, load from localStorage. - this.loadLinkedWalletsFromLocal(); - } catch {} - } - - private loadLinkedWalletsFromLocal() { - try { - const raw = localStorage.getItem("encryptid_linked_wallets"); - if (!raw) return; - // The data is encrypted — we can't decrypt without the key. - // The component receives decrypted entries via custom event from the auth layer. - // For initial render, check if we have cached decrypted entries. - const cached = sessionStorage.getItem("_linked_wallets_cache"); - if (cached) { - this.linkedWallets = JSON.parse(cached); - } + // Server decrypts at rest and returns entry data directly + this.linkedWallets = (data.wallets || []).map((w: any) => ({ + id: w.id, + address: w.address || "", + type: w.type || "eoa", + label: w.label || "", + providerName: w.providerName, + providerRdns: w.providerRdns, + safeInfo: w.safeInfo, + })); + this.render(); } catch {} } @@ -433,22 +427,19 @@ class FolkWalletViewer extends HTMLElement { params: [siweMessage, walletAddress], }); - // 5. Hash the address for dedup + // 5. Hash the address for dedup (salted with user ID from session) const addressHash = await this.hashAddress(walletAddress); - // 6. Encrypt wallet entry for server storage - const entryData = JSON.stringify({ + // 6. Build entry data — server encrypts at rest with AES-256-GCM + const entry = { address: walletAddress, type: "eoa", label: provider.name, providerRdns: provider.rdns, providerName: provider.name, - }); - // Store as plaintext for now (server-side encryption uses the encrypted blob pattern) - const ciphertext = btoa(entryData); - const iv = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(12)))); + }; - // 7. Verify with server + // 7. Verify with server (server handles encryption at rest) const verifyRes = await fetch("/encryptid/api/wallet-link/verify", { method: "POST", headers: { @@ -458,10 +449,9 @@ class FolkWalletViewer extends HTMLElement { body: JSON.stringify({ message: siweMessage, signature, - ciphertext, - iv, addressHash, walletType: "eoa", + entry, }), }); @@ -483,9 +473,6 @@ class FolkWalletViewer extends HTMLElement { }; this.linkedWallets.push(newWallet); - // Cache for session persistence - sessionStorage.setItem("_linked_wallets_cache", JSON.stringify(this.linkedWallets)); - this.stopProviderDiscovery(); } catch (err: any) { this.linkError = err?.message || "Failed to link wallet"; @@ -506,7 +493,6 @@ class FolkWalletViewer extends HTMLElement { }); if (res.ok) { this.linkedWallets = this.linkedWallets.filter(w => w.id !== id); - sessionStorage.setItem("_linked_wallets_cache", JSON.stringify(this.linkedWallets)); this.render(); } } catch {} @@ -523,7 +509,10 @@ class FolkWalletViewer extends HTMLElement { } private async hashAddress(address: string): Promise { - const normalized = address.toLowerCase(); + // Salt with user ID to prevent cross-user address correlation + const session = localStorage.getItem("encryptid_session"); + const userId = session ? (JSON.parse(session).claims?.sub || "") : ""; + const normalized = userId + ":" + address.toLowerCase(); const encoded = new TextEncoder().encode(normalized); const hash = await crypto.subtle.digest("SHA-256", encoded); const bytes = new Uint8Array(hash); @@ -949,8 +938,8 @@ class FolkWalletViewer extends HTMLElement { .map((b) => ` - ${b.token?.symbol || "ETH"} - ${b.token?.name || "Ether"} + ${this.esc(b.token?.symbol || "ETH")} + ${this.esc(b.token?.name || "Ether")} ${this.formatBalance(b.balance, b.token?.decimals || 18)} ${this.formatUSD(b.fiatBalance)} diff --git a/src/encryptid/linked-wallets.ts b/src/encryptid/linked-wallets.ts index e24c1e7..45bfd48 100644 --- a/src/encryptid/linked-wallets.ts +++ b/src/encryptid/linked-wallets.ts @@ -269,8 +269,9 @@ export function resetLinkedWalletStore(): void { // UTILITIES // ============================================================================ -export async function hashAddress(address: string): Promise { - const normalized = address.toLowerCase(); +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); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index e220439..02a3554 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -2706,6 +2706,55 @@ app.post('/encryptid/api/safe/verify', async (c) => { } }); +// ============================================================================ +// SERVER-SIDE ENCRYPTION FOR LINKED WALLETS +// ============================================================================ + +// Derive a dedicated AES-256-GCM key from JWT_SECRET via HKDF. +// This encrypts linked wallet data at rest in the DB. +// (Linked wallets are NOT zero-knowledge — the server sees the address during +// SIWE verification — but we encrypt at rest to prevent casual DB snooping.) +let _walletLinkKey: CryptoKey | null = null; +async function getWalletLinkKey(): Promise { + if (_walletLinkKey) return _walletLinkKey; + const raw = new TextEncoder().encode(CONFIG.jwtSecret); + const base = await crypto.subtle.importKey('raw', raw, { name: 'HKDF' }, false, ['deriveKey']); + _walletLinkKey = await crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new TextEncoder().encode('linked-wallets-v1'), info: new Uint8Array(0) }, + base, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); + return _walletLinkKey; +} + +async function encryptWalletEntry(plaintext: string): Promise<{ ciphertext: string; iv: string }> { + const key = await getWalletLinkKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = new Uint8Array(await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + new TextEncoder().encode(plaintext), + )); + return { + ciphertext: Buffer.from(ct).toString('base64url'), + iv: Buffer.from(iv).toString('base64url'), + }; +} + +async function decryptWalletEntry(ciphertext: string, iv: string): Promise { + const key = await getWalletLinkKey(); + const ctBuf = Buffer.from(ciphertext, 'base64url'); + const ivBuf = Buffer.from(iv, 'base64url'); + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: ivBuf }, + key, + ctBuf, + ); + return new TextDecoder().decode(plain); +} + // ============================================================================ // LINKED WALLET ROUTES (SIWE-verified external wallet associations) // ============================================================================ @@ -2739,10 +2788,10 @@ app.post('/encryptid/api/wallet-link/verify', async (c) => { return c.json({ error: 'Elevated authentication required to link wallets' }, 403); } - const { message, signature, ciphertext, iv, addressHash, walletType } = await c.req.json(); + const { message, signature, addressHash, walletType, entry } = await c.req.json(); - if (!message || !signature || !ciphertext || !iv || !addressHash) { - return c.json({ error: 'Missing required fields: message, signature, ciphertext, iv, addressHash' }, 400); + if (!message || !signature || !addressHash) { + return c.json({ error: 'Missing required fields: message, signature, addressHash' }, 400); } // Parse and verify SIWE message using viem/siwe @@ -2792,13 +2841,19 @@ app.post('/encryptid/api/wallet-link/verify', async (c) => { return c.json({ error: 'This wallet is already linked to your account' }, 409); } - // Store encrypted wallet link + // Encrypt wallet entry data server-side (AES-256-GCM at rest) const id = crypto.randomUUID(); const source = walletType === 'safe' ? 'external-safe' : 'external-eoa'; + const entryData = JSON.stringify(entry || { + address: parsed.address, + type: walletType || 'eoa', + label: 'External Wallet', + }); + const encrypted = await encryptWalletEntry(entryData); const stored = await createLinkedWallet(claims.sub, { id, - ciphertext, - iv, + ciphertext: encrypted.ciphertext, + iv: encrypted.iv, addressHash, source: source as 'external-eoa' | 'external-safe', }); @@ -2810,22 +2865,30 @@ app.post('/encryptid/api/wallet-link/verify', async (c) => { } }); -// GET /encryptid/api/wallet-link/list — Return encrypted blobs for client-side decryption. +// GET /encryptid/api/wallet-link/list — Decrypt and return linked wallet entries. app.get('/encryptid/api/wallet-link/list', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Authentication required' }, 401); const wallets = await getLinkedWallets(claims.sub); - return c.json({ - wallets: wallets.map(w => ({ - id: w.id, - ciphertext: w.ciphertext, - iv: w.iv, - source: w.source, - verified: w.verified, - linkedAt: w.linkedAt, - })), - }); + const entries = []; + for (const w of wallets) { + try { + const decrypted = await decryptWalletEntry(w.ciphertext, w.iv); + const entry = JSON.parse(decrypted); + entries.push({ + id: w.id, + source: w.source, + verified: w.verified, + linkedAt: w.linkedAt, + ...entry, + }); + } catch { + // Skip entries that can't be decrypted (corrupted or from old Base64 format) + console.warn(`EncryptID: Failed to decrypt linked wallet ${w.id} for user ${claims.sub}`); + } + } + return c.json({ wallets: entries }); }); // DELETE /encryptid/api/wallet-link/:id — Remove a linked wallet.