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:
parent
8723aae3f6
commit
45f5cea095
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue