fix(security): AES-256-GCM encryption at rest, XSS escape, salted hashes

- C-1: Replace Base64 fake encryption with real AES-256-GCM server-side
  encryption for linked wallet data (HKDF-derived key from JWT_SECRET)
- H-1: Escape token name/symbol in balance table to prevent XSS
- H-2: Salt address hash with user ID to prevent cross-user correlation
- M-4: Remove cleartext sessionStorage cache for linked wallets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-09 17:42:09 -07:00
parent 8723aae3f6
commit 45f5cea095
3 changed files with 106 additions and 53 deletions

View File

@ -145,23 +145,17 @@ class FolkWalletViewer extends HTMLElement {
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
// The server returns encrypted blobs. For the UI, we need the client-side // Server decrypts at rest and returns entry data directly
// decrypted data from LinkedWalletStore. For now, load from localStorage. this.linkedWallets = (data.wallets || []).map((w: any) => ({
this.loadLinkedWalletsFromLocal(); id: w.id,
} catch {} address: w.address || "",
} type: w.type || "eoa",
label: w.label || "",
private loadLinkedWalletsFromLocal() { providerName: w.providerName,
try { providerRdns: w.providerRdns,
const raw = localStorage.getItem("encryptid_linked_wallets"); safeInfo: w.safeInfo,
if (!raw) return; }));
// The data is encrypted — we can't decrypt without the key. this.render();
// 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);
}
} catch {} } catch {}
} }
@ -433,22 +427,19 @@ class FolkWalletViewer extends HTMLElement {
params: [siweMessage, walletAddress], 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); const addressHash = await this.hashAddress(walletAddress);
// 6. Encrypt wallet entry for server storage // 6. Build entry data — server encrypts at rest with AES-256-GCM
const entryData = JSON.stringify({ const entry = {
address: walletAddress, address: walletAddress,
type: "eoa", type: "eoa",
label: provider.name, label: provider.name,
providerRdns: provider.rdns, providerRdns: provider.rdns,
providerName: provider.name, 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", { const verifyRes = await fetch("/encryptid/api/wallet-link/verify", {
method: "POST", method: "POST",
headers: { headers: {
@ -458,10 +449,9 @@ class FolkWalletViewer extends HTMLElement {
body: JSON.stringify({ body: JSON.stringify({
message: siweMessage, message: siweMessage,
signature, signature,
ciphertext,
iv,
addressHash, addressHash,
walletType: "eoa", walletType: "eoa",
entry,
}), }),
}); });
@ -483,9 +473,6 @@ class FolkWalletViewer extends HTMLElement {
}; };
this.linkedWallets.push(newWallet); this.linkedWallets.push(newWallet);
// Cache for session persistence
sessionStorage.setItem("_linked_wallets_cache", JSON.stringify(this.linkedWallets));
this.stopProviderDiscovery(); this.stopProviderDiscovery();
} catch (err: any) { } catch (err: any) {
this.linkError = err?.message || "Failed to link wallet"; this.linkError = err?.message || "Failed to link wallet";
@ -506,7 +493,6 @@ class FolkWalletViewer extends HTMLElement {
}); });
if (res.ok) { if (res.ok) {
this.linkedWallets = this.linkedWallets.filter(w => w.id !== id); this.linkedWallets = this.linkedWallets.filter(w => w.id !== id);
sessionStorage.setItem("_linked_wallets_cache", JSON.stringify(this.linkedWallets));
this.render(); this.render();
} }
} catch {} } catch {}
@ -523,7 +509,10 @@ class FolkWalletViewer extends HTMLElement {
} }
private async hashAddress(address: string): Promise<string> { private async hashAddress(address: string): Promise<string> {
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 encoded = new TextEncoder().encode(normalized);
const hash = await crypto.subtle.digest("SHA-256", encoded); const hash = await crypto.subtle.digest("SHA-256", encoded);
const bytes = new Uint8Array(hash); const bytes = new Uint8Array(hash);
@ -949,8 +938,8 @@ class FolkWalletViewer extends HTMLElement {
.map((b) => ` .map((b) => `
<tr> <tr>
<td> <td>
<span class="token-symbol">${b.token?.symbol || "ETH"}</span> <span class="token-symbol">${this.esc(b.token?.symbol || "ETH")}</span>
<span class="token-name">${b.token?.name || "Ether"}</span> <span class="token-name">${this.esc(b.token?.name || "Ether")}</span>
</td> </td>
<td class="amount-cell">${this.formatBalance(b.balance, b.token?.decimals || 18)}</td> <td class="amount-cell">${this.formatBalance(b.balance, b.token?.decimals || 18)}</td>
<td class="amount-cell fiat">${this.formatUSD(b.fiatBalance)}</td> <td class="amount-cell fiat">${this.formatUSD(b.fiatBalance)}</td>

View File

@ -269,8 +269,9 @@ export function resetLinkedWalletStore(): void {
// UTILITIES // UTILITIES
// ============================================================================ // ============================================================================
export async function hashAddress(address: string): Promise<string> { export async function hashAddress(address: string, userId?: string): Promise<string> {
const normalized = address.toLowerCase(); // Salt with userId to prevent cross-user address correlation
const normalized = (userId ? userId + ':' : '') + address.toLowerCase();
const encoded = new TextEncoder().encode(normalized); const encoded = new TextEncoder().encode(normalized);
const hash = await crypto.subtle.digest('SHA-256', encoded); const hash = await crypto.subtle.digest('SHA-256', encoded);
return bufferToBase64url(hash); return bufferToBase64url(hash);

View File

@ -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<CryptoKey> {
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<string> {
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) // 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); 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) { if (!message || !signature || !addressHash) {
return c.json({ error: 'Missing required fields: message, signature, ciphertext, iv, addressHash' }, 400); return c.json({ error: 'Missing required fields: message, signature, addressHash' }, 400);
} }
// Parse and verify SIWE message using viem/siwe // 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); 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 id = crypto.randomUUID();
const source = walletType === 'safe' ? 'external-safe' : 'external-eoa'; 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, { const stored = await createLinkedWallet(claims.sub, {
id, id,
ciphertext, ciphertext: encrypted.ciphertext,
iv, iv: encrypted.iv,
addressHash, addressHash,
source: source as 'external-eoa' | 'external-safe', 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) => { app.get('/encryptid/api/wallet-link/list', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization')); const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401); if (!claims) return c.json({ error: 'Authentication required' }, 401);
const wallets = await getLinkedWallets(claims.sub); const wallets = await getLinkedWallets(claims.sub);
return c.json({ const entries = [];
wallets: wallets.map(w => ({ for (const w of wallets) {
id: w.id, try {
ciphertext: w.ciphertext, const decrypted = await decryptWalletEntry(w.ciphertext, w.iv);
iv: w.iv, const entry = JSON.parse(decrypted);
source: w.source, entries.push({
verified: w.verified, id: w.id,
linkedAt: w.linkedAt, 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. // DELETE /encryptid/api/wallet-link/:id — Remove a linked wallet.