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;
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<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 hash = await crypto.subtle.digest("SHA-256", encoded);
const bytes = new Uint8Array(hash);
@ -949,8 +938,8 @@ class FolkWalletViewer extends HTMLElement {
.map((b) => `
<tr>
<td>
<span class="token-symbol">${b.token?.symbol || "ETH"}</span>
<span class="token-name">${b.token?.name || "Ether"}</span>
<span class="token-symbol">${this.esc(b.token?.symbol || "ETH")}</span>
<span class="token-name">${this.esc(b.token?.name || "Ether")}</span>
</td>
<td class="amount-cell">${this.formatBalance(b.balance, b.token?.decimals || 18)}</td>
<td class="amount-cell fiat">${this.formatUSD(b.fiatBalance)}</td>

View File

@ -269,8 +269,9 @@ export function resetLinkedWalletStore(): void {
// UTILITIES
// ============================================================================
export async function hashAddress(address: string): Promise<string> {
const normalized = address.toLowerCase();
export async function hashAddress(address: string, userId?: string): Promise<string> {
// 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);

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)
// ============================================================================
@ -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.