From 9695e9577adde96ffc609902825bafa349276903 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 23 Mar 2026 16:50:21 -0700 Subject: [PATCH] feat(encryptid): encrypt all PII at rest in database AES-256-GCM encryption for 18 PII fields across 6 tables (users, guardians, identity_invites, space_invites, notifications, fund_claims). HMAC-SHA256 hash indexes for email/UP address lookups. Keys derived from JWT_SECRET via HKDF with dedicated salts. Dual-write to both plaintext and _enc columns during transition; row mappers decrypt with plaintext fallback. Includes idempotent backfill migration script. Co-Authored-By: Claude Opus 4.6 --- src/encryptid/db.ts | 260 ++++++++++++++++-------- src/encryptid/migrations/encrypt-pii.ts | 163 +++++++++++++++ src/encryptid/schema.sql | 42 ++++ src/encryptid/server-crypto.ts | 89 ++++++++ src/encryptid/server.ts | 13 +- 5 files changed, 477 insertions(+), 90 deletions(-) create mode 100644 src/encryptid/migrations/encrypt-pii.ts create mode 100644 src/encryptid/server-crypto.ts diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index a940f28..6231a0e 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -8,6 +8,7 @@ import postgres from 'postgres'; import { readFileSync } from 'fs'; import { join } from 'path'; +import { encryptField, decryptField, hashForLookup } from './server-crypto'; // ============================================================================ // CONNECTION @@ -224,12 +225,23 @@ export async function cleanExpiredChallenges(): Promise { // ============================================================================ export async function setUserEmail(userId: string, email: string): Promise { - await sql`UPDATE users SET email = ${email}, profile_email = ${email}, updated_at = NOW() WHERE id = ${userId}`; + const [emailEnc, profileEmailEnc, emailHash] = await Promise.all([ + encryptField(email), + encryptField(email), + hashForLookup(email), + ]); + await sql`UPDATE users SET email = ${email}, profile_email = ${email}, + email_enc = ${emailEnc}, profile_email_enc = ${profileEmailEnc}, email_hash = ${emailHash}, + updated_at = NOW() WHERE id = ${userId}`; } export async function getUserByEmail(email: string) { - const [user] = await sql`SELECT * FROM users WHERE email = ${email}`; - return user || null; + const hash = await hashForLookup(email); + // Try hash lookup first, fall back to plaintext for pre-migration rows + const [user] = await sql`SELECT * FROM users WHERE email_hash = ${hash}`; + if (user) return user; + const [legacy] = await sql`SELECT * FROM users WHERE email = ${email} AND email_hash IS NULL`; + return legacy || null; } export async function getUserById(userId: string) { @@ -412,12 +424,16 @@ export interface StoredGuardian { createdAt: number; } -function rowToGuardian(row: any): StoredGuardian { +async function rowToGuardian(row: any): Promise { + const [nameDecrypted, emailDecrypted] = await Promise.all([ + decryptField(row.name_enc), + decryptField(row.email_enc), + ]); return { id: row.id, userId: row.user_id, - name: row.name, - email: row.email || null, + name: nameDecrypted ?? row.name, + email: emailDecrypted ?? row.email ?? null, guardianUserId: row.guardian_user_id || null, status: row.status, inviteToken: row.invite_token || null, @@ -435,9 +451,10 @@ export async function addGuardian( inviteToken: string, inviteExpiresAt: number, ): Promise { + const [nameEnc, emailEnc] = await Promise.all([encryptField(name), encryptField(email)]); const rows = await sql` - INSERT INTO guardians (id, user_id, name, email, invite_token, invite_expires_at) - VALUES (${id}, ${userId}, ${name}, ${email}, ${inviteToken}, ${new Date(inviteExpiresAt)}) + INSERT INTO guardians (id, user_id, name, email, name_enc, email_enc, invite_token, invite_expires_at) + VALUES (${id}, ${userId}, ${name}, ${email}, ${nameEnc}, ${emailEnc}, ${inviteToken}, ${new Date(inviteExpiresAt)}) RETURNING * `; return rowToGuardian(rows[0]); @@ -449,13 +466,13 @@ export async function getGuardians(userId: string): Promise { WHERE user_id = ${userId} AND status != 'revoked' ORDER BY created_at ASC `; - return rows.map(rowToGuardian); + return Promise.all(rows.map(rowToGuardian)); } export async function getGuardianByInviteToken(token: string): Promise { const rows = await sql`SELECT * FROM guardians WHERE invite_token = ${token}`; if (rows.length === 0) return null; - return rowToGuardian(rows[0]); + return await rowToGuardian(rows[0]); } export async function acceptGuardianInvite(guardianId: string, guardianUserId: string): Promise { @@ -477,7 +494,7 @@ export async function removeGuardian(guardianId: string, userId: string): Promis export async function getGuardianById(guardianId: string): Promise { const rows = await sql`SELECT * FROM guardians WHERE id = ${guardianId}`; if (rows.length === 0) return null; - return rowToGuardian(rows[0]); + return await rowToGuardian(rows[0]); } export async function getGuardianships(guardianUserId: string): Promise { @@ -486,7 +503,7 @@ export async function getGuardianships(guardianUserId: string): Promise { + const [bioDecrypted, avatarUrlDecrypted, profileEmailDecrypted, walletDecrypted] = await Promise.all([ + decryptField(row.bio_enc), + decryptField(row.avatar_url_enc), + decryptField(row.profile_email_enc), + decryptField(row.wallet_address_enc), + ]); return { userId: row.id, username: row.username, displayName: row.display_name || null, - bio: row.bio || null, - avatarUrl: row.avatar_url || null, - profileEmail: row.profile_email || null, + bio: bioDecrypted ?? row.bio ?? null, + avatarUrl: avatarUrlDecrypted ?? row.avatar_url ?? null, + profileEmail: profileEmailDecrypted ?? row.profile_email ?? null, profileEmailIsRecovery: row.profile_email_is_recovery || false, did: row.did || null, - walletAddress: row.wallet_address || null, + walletAddress: walletDecrypted ?? row.wallet_address ?? null, emailForwardEnabled: row.email_forward_enabled || false, emailForwardMailcowId: row.email_forward_mailcow_id || null, createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), @@ -667,7 +690,7 @@ function rowToProfile(row: any): StoredUserProfile { export async function getUserProfile(userId: string): Promise { const [row] = await sql`SELECT * FROM users WHERE id = ${userId}`; if (!row) return null; - return rowToProfile(row); + return await rowToProfile(row); } export interface UserProfileUpdates { @@ -684,11 +707,23 @@ export async function updateUserProfile(userId: string, updates: UserProfileUpda const values: any[] = []; if (updates.displayName !== undefined) { sets.push('display_name'); values.push(updates.displayName); } - if (updates.bio !== undefined) { sets.push('bio'); values.push(updates.bio); } - if (updates.avatarUrl !== undefined) { sets.push('avatar_url'); values.push(updates.avatarUrl); } - if (updates.profileEmail !== undefined) { sets.push('profile_email'); values.push(updates.profileEmail); } + if (updates.bio !== undefined) { + sets.push('bio'); values.push(updates.bio); + const enc = await encryptField(updates.bio); sets.push('bio_enc'); values.push(enc); + } + if (updates.avatarUrl !== undefined) { + sets.push('avatar_url'); values.push(updates.avatarUrl); + const enc = await encryptField(updates.avatarUrl); sets.push('avatar_url_enc'); values.push(enc); + } + if (updates.profileEmail !== undefined) { + sets.push('profile_email'); values.push(updates.profileEmail); + const enc = await encryptField(updates.profileEmail); sets.push('profile_email_enc'); values.push(enc); + } if (updates.profileEmailIsRecovery !== undefined) { sets.push('profile_email_is_recovery'); values.push(updates.profileEmailIsRecovery); } - if (updates.walletAddress !== undefined) { sets.push('wallet_address'); values.push(updates.walletAddress); } + if (updates.walletAddress !== undefined) { + sets.push('wallet_address'); values.push(updates.walletAddress); + const enc = await encryptField(updates.walletAddress); sets.push('wallet_address_enc'); values.push(enc); + } if (sets.length === 0) { return getUserProfile(userId); @@ -870,15 +905,16 @@ export async function getEmailForwardStatus(userId: string): Promise<{ profileEmail: string | null; } | null> { const [row] = await sql` - SELECT username, profile_email, email_forward_enabled, email_forward_mailcow_id + SELECT username, profile_email, profile_email_enc, email_forward_enabled, email_forward_mailcow_id FROM users WHERE id = ${userId} `; if (!row) return null; + const profileEmailDecrypted = await decryptField(row.profile_email_enc); return { enabled: row.email_forward_enabled || false, mailcowId: row.email_forward_mailcow_id || null, username: row.username, - profileEmail: row.profile_email || null, + profileEmail: profileEmailDecrypted ?? row.profile_email ?? null, }; } @@ -907,21 +943,24 @@ export interface AdminUserInfo { export async function listAllUsers(): Promise { const rows = await sql` - SELECT u.id, u.username, u.display_name, u.did, u.email, u.created_at, + SELECT u.id, u.username, u.display_name, u.did, u.email, u.email_enc, u.created_at, (SELECT COUNT(*)::int FROM credentials c WHERE c.user_id = u.id) as credential_count, (SELECT COUNT(*)::int FROM space_members sm WHERE sm.user_did = u.did) as space_membership_count FROM users u ORDER BY u.created_at DESC `; - return rows.map(row => ({ - userId: row.id, - username: row.username, - displayName: row.display_name || null, - did: row.did || null, - email: row.email || null, - createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), - credentialCount: Number(row.credential_count), - spaceMembershipCount: Number(row.space_membership_count), + return Promise.all(rows.map(async row => { + const emailDecrypted = await decryptField(row.email_enc); + return { + userId: row.id, + username: row.username, + displayName: row.display_name || null, + did: row.did || null, + email: emailDecrypted ?? row.email ?? null, + createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), + credentialCount: Number(row.credential_count), + spaceMembershipCount: Number(row.space_membership_count), + }; })); } @@ -975,11 +1014,12 @@ export interface StoredSpaceInvite { acceptedByDid: string | null; } -function rowToInvite(row: any): StoredSpaceInvite { +async function rowToInvite(row: any): Promise { + const emailDecrypted = await decryptField(row.email_enc); return { id: row.id, spaceSlug: row.space_slug, - email: row.email || null, + email: emailDecrypted ?? row.email ?? null, role: row.role, token: row.token, invitedBy: row.invited_by, @@ -1000,18 +1040,19 @@ export async function createSpaceInvite( expiresAt: number, email?: string, ): Promise { + const emailEnc = await encryptField(email || null); const rows = await sql` - INSERT INTO space_invites (id, space_slug, email, role, token, invited_by, expires_at) - VALUES (${id}, ${spaceSlug}, ${email || null}, ${role}, ${token}, ${invitedBy}, ${new Date(expiresAt)}) + INSERT INTO space_invites (id, space_slug, email, email_enc, role, token, invited_by, expires_at) + VALUES (${id}, ${spaceSlug}, ${email || null}, ${emailEnc}, ${role}, ${token}, ${invitedBy}, ${new Date(expiresAt)}) RETURNING * `; - return rowToInvite(rows[0]); + return await rowToInvite(rows[0]); } export async function getSpaceInviteByToken(token: string): Promise { const rows = await sql`SELECT * FROM space_invites WHERE token = ${token}`; if (rows.length === 0) return null; - return rowToInvite(rows[0]); + return await rowToInvite(rows[0]); } export async function listSpaceInvites(spaceSlug: string): Promise { @@ -1020,7 +1061,7 @@ export async function listSpaceInvites(spaceSlug: string): Promise { @@ -1031,7 +1072,7 @@ export async function acceptSpaceInvite(token: string, acceptedByDid: string): P RETURNING * `; if (rows.length === 0) return null; - return rowToInvite(rows[0]); + return await rowToInvite(rows[0]); } export async function revokeSpaceInvite(id: string, spaceSlug: string): Promise { @@ -1069,19 +1110,24 @@ export interface StoredNotification { expiresAt: string | null; } -function rowToNotification(row: any): StoredNotification { +async function rowToNotification(row: any): Promise { + const [titleDecrypted, bodyDecrypted, actorUsernameDecrypted] = await Promise.all([ + decryptField(row.title_enc), + decryptField(row.body_enc), + decryptField(row.actor_username_enc), + ]); return { id: row.id, userDid: row.user_did, category: row.category, eventType: row.event_type, - title: row.title, - body: row.body || null, + title: titleDecrypted ?? row.title, + body: bodyDecrypted ?? row.body ?? null, spaceSlug: row.space_slug || null, moduleId: row.module_id || null, actionUrl: row.action_url || null, actorDid: row.actor_did || null, - actorUsername: row.actor_username || null, + actorUsername: actorUsernameDecrypted ?? row.actor_username ?? null, metadata: row.metadata || {}, read: row.read, dismissed: row.dismissed, @@ -1109,8 +1155,13 @@ export async function createNotification(notif: { metadata?: Record; expiresAt?: Date; }): Promise { + const [titleEnc, bodyEnc, actorUsernameEnc] = await Promise.all([ + encryptField(notif.title), + encryptField(notif.body || null), + encryptField(notif.actorUsername || null), + ]); const rows = await sql` - INSERT INTO notifications (id, user_did, category, event_type, title, body, space_slug, module_id, action_url, actor_did, actor_username, metadata, expires_at) + INSERT INTO notifications (id, user_did, category, event_type, title, body, title_enc, body_enc, actor_username_enc, space_slug, module_id, action_url, actor_did, actor_username, metadata, expires_at) VALUES ( ${notif.id}, ${notif.userDid}, @@ -1118,6 +1169,9 @@ export async function createNotification(notif: { ${notif.eventType}, ${notif.title}, ${notif.body || null}, + ${titleEnc}, + ${bodyEnc}, + ${actorUsernameEnc}, ${notif.spaceSlug || null}, ${notif.moduleId || null}, ${notif.actionUrl || null}, @@ -1128,7 +1182,7 @@ export async function createNotification(notif: { ) RETURNING * `; - return rowToNotification(rows[0]); + return await rowToNotification(rows[0]); } export async function getUserNotifications( @@ -1164,7 +1218,7 @@ export async function getUserNotifications( ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset} `; } - return rows.map(rowToNotification); + return Promise.all(rows.map(rowToNotification)); } export async function getUnreadCount(userDid: string): Promise { @@ -1321,13 +1375,17 @@ export interface StoredFundClaim { claimedAt: number | null; } -function rowToFundClaim(row: any): StoredFundClaim { +async function rowToFundClaim(row: any): Promise { + const [emailDecrypted, walletDecrypted] = await Promise.all([ + decryptField(row.email_enc), + decryptField(row.wallet_address_enc), + ]); return { id: row.id, token: row.token, - emailHash: row.email_hash, - email: row.email || null, - walletAddress: row.wallet_address, + emailHash: row.email_hmac || row.email_hash, + email: emailDecrypted ?? row.email ?? null, + walletAddress: walletDecrypted ?? row.wallet_address, openfortPlayerId: row.openfort_player_id || null, fiatAmount: row.fiat_amount || null, fiatCurrency: row.fiat_currency || 'USD', @@ -1354,14 +1412,22 @@ export async function createFundClaim(claim: { provider?: string; expiresAt: number; }): Promise { + const [emailEnc, walletEnc, emailHmac] = await Promise.all([ + encryptField(claim.email), + encryptField(claim.walletAddress), + hashForLookup(claim.email), + ]); const rows = await sql` - INSERT INTO fund_claims (id, token, email_hash, email, wallet_address, openfort_player_id, fiat_amount, fiat_currency, session_id, provider, expires_at) + INSERT INTO fund_claims (id, token, email_hash, email, email_enc, email_hmac, wallet_address, wallet_address_enc, openfort_player_id, fiat_amount, fiat_currency, session_id, provider, expires_at) VALUES ( ${claim.id}, ${claim.token}, ${claim.emailHash}, ${claim.email}, + ${emailEnc}, + ${emailHmac}, ${claim.walletAddress}, + ${walletEnc}, ${claim.openfortPlayerId || null}, ${claim.fiatAmount || null}, ${claim.fiatCurrency || 'USD'}, @@ -1371,33 +1437,41 @@ export async function createFundClaim(claim: { ) RETURNING * `; - return rowToFundClaim(rows[0]); + return await rowToFundClaim(rows[0]); } export async function getFundClaimByToken(token: string): Promise { const rows = await sql`SELECT * FROM fund_claims WHERE token = ${token}`; if (rows.length === 0) return null; - return rowToFundClaim(rows[0]); + return await rowToFundClaim(rows[0]); } export async function getFundClaimsByEmailHash(emailHash: string): Promise { - const rows = await sql` + // Try HMAC hash first, fall back to legacy SHA-256 hash + let rows = await sql` SELECT * FROM fund_claims - WHERE email_hash = ${emailHash} AND status IN ('pending', 'resent') AND expires_at > NOW() + WHERE email_hmac = ${emailHash} AND status IN ('pending', 'resent') AND expires_at > NOW() ORDER BY created_at DESC `; - return rows.map(rowToFundClaim); + if (rows.length === 0) { + rows = await sql` + SELECT * FROM fund_claims + WHERE email_hash = ${emailHash} AND email_hmac IS NULL AND status IN ('pending', 'resent') AND expires_at > NOW() + ORDER BY created_at DESC + `; + } + return Promise.all(rows.map(rowToFundClaim)); } export async function acceptFundClaim(token: string, userId: string): Promise { const rows = await sql` UPDATE fund_claims - SET status = 'claimed', claimed_by_user_id = ${userId}, claimed_at = NOW(), email = NULL + SET status = 'claimed', claimed_by_user_id = ${userId}, claimed_at = NOW(), email = NULL, email_enc = NULL WHERE token = ${token} AND status IN ('pending', 'resent') AND expires_at > NOW() RETURNING * `; if (rows.length === 0) return null; - return rowToFundClaim(rows[0]); + return await rowToFundClaim(rows[0]); } export async function accumulateFundClaim(claimId: string, additionalAmount: string, expiresAt: number): Promise { @@ -1409,16 +1483,16 @@ export async function accumulateFundClaim(claimId: string, additionalAmount: str RETURNING * `; if (rows.length === 0) return null; - return rowToFundClaim(rows[0]); + return await rowToFundClaim(rows[0]); } export async function expireFundClaim(claimId: string): Promise { - await sql`UPDATE fund_claims SET status = 'expired', email = NULL WHERE id = ${claimId} AND status IN ('pending', 'resent')`; + await sql`UPDATE fund_claims SET status = 'expired', email = NULL, email_enc = NULL WHERE id = ${claimId} AND status IN ('pending', 'resent')`; } export async function cleanExpiredFundClaims(): Promise { // Null out email on expired claims, then mark them expired - await sql`UPDATE fund_claims SET email = NULL, status = 'expired' WHERE status IN ('pending', 'resent') AND expires_at < NOW()`; + await sql`UPDATE fund_claims SET email = NULL, email_enc = NULL, status = 'expired' WHERE status IN ('pending', 'resent') AND expires_at < NOW()`; const result = await sql`DELETE FROM fund_claims WHERE status = 'expired' AND expires_at < NOW() - INTERVAL '30 days'`; return result.count; } @@ -1580,14 +1654,18 @@ export interface StoredIdentityInvite { claimedAt: number | null; } -function mapInviteRow(r: any): StoredIdentityInvite { +async function mapInviteRow(r: any): Promise { + const [emailDecrypted, messageDecrypted] = await Promise.all([ + decryptField(r.email_enc), + decryptField(r.message_enc), + ]); return { id: r.id, token: r.token, - email: r.email, + email: emailDecrypted ?? r.email, invitedByUserId: r.invited_by_user_id, invitedByUsername: r.invited_by_username, - message: r.message, + message: messageDecrypted ?? r.message ?? null, spaceSlug: r.space_slug, spaceRole: r.space_role, clientId: r.client_id || null, @@ -1611,36 +1689,46 @@ export async function createIdentityInvite(invite: { clientId?: string; expiresAt: number; }): Promise { + const [emailEnc, emailHash, messageEnc] = await Promise.all([ + encryptField(invite.email), + hashForLookup(invite.email), + encryptField(invite.message || null), + ]); const rows = await sql` - INSERT INTO identity_invites (id, token, email, invited_by_user_id, invited_by_username, message, space_slug, space_role, client_id, expires_at) - VALUES (${invite.id}, ${invite.token}, ${invite.email}, ${invite.invitedByUserId}, - ${invite.invitedByUsername}, ${invite.message || null}, + INSERT INTO identity_invites (id, token, email, email_enc, email_hash, invited_by_user_id, invited_by_username, message, message_enc, space_slug, space_role, client_id, expires_at) + VALUES (${invite.id}, ${invite.token}, ${invite.email}, ${emailEnc}, ${emailHash}, ${invite.invitedByUserId}, + ${invite.invitedByUsername}, ${invite.message || null}, ${messageEnc}, ${invite.spaceSlug || null}, ${invite.spaceRole || 'member'}, ${invite.clientId || null}, ${new Date(invite.expiresAt).toISOString()}) RETURNING * `; - return mapInviteRow(rows[0]); + return await mapInviteRow(rows[0]); } export async function getIdentityInviteByToken(token: string): Promise { const rows = await sql`SELECT * FROM identity_invites WHERE token = ${token}`; - return rows.length ? mapInviteRow(rows[0]) : null; + return rows.length ? await mapInviteRow(rows[0]) : null; } export async function getIdentityInvitesByEmail(email: string): Promise { - const rows = await sql`SELECT * FROM identity_invites WHERE email = ${email} ORDER BY created_at DESC`; - return rows.map(mapInviteRow); + const hash = await hashForLookup(email); + // Try hash lookup first, fall back to plaintext for pre-migration rows + let rows = await sql`SELECT * FROM identity_invites WHERE email_hash = ${hash} ORDER BY created_at DESC`; + if (rows.length === 0) { + rows = await sql`SELECT * FROM identity_invites WHERE email = ${email} AND email_hash IS NULL ORDER BY created_at DESC`; + } + return Promise.all(rows.map(mapInviteRow)); } export async function getIdentityInvitesByInviter(userId: string): Promise { const rows = await sql`SELECT * FROM identity_invites WHERE invited_by_user_id = ${userId} ORDER BY created_at DESC`; - return rows.map(mapInviteRow); + return Promise.all(rows.map(mapInviteRow)); } export async function getIdentityInvitesByClient(clientId: string): Promise { const rows = await sql`SELECT * FROM identity_invites WHERE client_id = ${clientId} ORDER BY created_at DESC`; - return rows.map(mapInviteRow); + return Promise.all(rows.map(mapInviteRow)); } export async function claimIdentityInvite(token: string, claimedByUserId: string): Promise { @@ -1650,7 +1738,7 @@ export async function claimIdentityInvite(token: string, claimedByUserId: string WHERE token = ${token} AND status = 'pending' AND expires_at > NOW() RETURNING * `; - return rows.length ? mapInviteRow(rows[0]) : null; + return rows.length ? await mapInviteRow(rows[0]) : null; } export async function revokeIdentityInvite(id: string, userId: string): Promise { @@ -2191,12 +2279,13 @@ export interface StoredUniversalProfile { export async function getUserUPAddress(userId: string): Promise { const [row] = await sql` - SELECT up_address, up_key_manager_address, up_chain_id, up_deployed_at + SELECT up_address, up_address_enc, up_key_manager_address, up_chain_id, up_deployed_at FROM users WHERE id = ${userId} AND up_address IS NOT NULL `; if (!row) return null; + const upDecrypted = await decryptField(row.up_address_enc); return { - upAddress: row.up_address, + upAddress: upDecrypted ?? row.up_address, keyManagerAddress: row.up_key_manager_address, chainId: row.up_chain_id, deployedAt: new Date(row.up_deployed_at), @@ -2209,9 +2298,12 @@ export async function setUserUPAddress( keyManagerAddress: string, chainId: number, ): Promise { + const [upEnc, upHash] = await Promise.all([encryptField(upAddress), hashForLookup(upAddress)]); await sql` UPDATE users SET up_address = ${upAddress}, + up_address_enc = ${upEnc}, + up_address_hash = ${upHash}, up_key_manager_address = ${keyManagerAddress}, up_chain_id = ${chainId}, up_deployed_at = NOW() @@ -2220,9 +2312,13 @@ export async function setUserUPAddress( } export async function getUserByUPAddress(upAddress: string): Promise<{ userId: string; username: string } | null> { - const [row] = await sql`SELECT id, username FROM users WHERE up_address = ${upAddress}`; - if (!row) return null; - return { userId: row.id, username: row.username }; + const hash = await hashForLookup(upAddress); + // Try hash lookup first, fall back to plaintext for pre-migration rows + const [row] = await sql`SELECT id, username FROM users WHERE up_address_hash = ${hash}`; + if (row) return { userId: row.id, username: row.username }; + const [legacy] = await sql`SELECT id, username FROM users WHERE up_address = ${upAddress} AND up_address_hash IS NULL`; + if (!legacy) return null; + return { userId: legacy.id, username: legacy.username }; } export { sql }; diff --git a/src/encryptid/migrations/encrypt-pii.ts b/src/encryptid/migrations/encrypt-pii.ts new file mode 100644 index 0000000..038c01e --- /dev/null +++ b/src/encryptid/migrations/encrypt-pii.ts @@ -0,0 +1,163 @@ +#!/usr/bin/env bun +/** + * Backfill migration: encrypt PII fields at rest + * + * Usage: docker exec encryptid bun run src/encryptid/migrations/encrypt-pii.ts + * + * Idempotent — only processes rows where _enc IS NULL. + * Batched for notifications (500/batch), unbatched for smaller tables. + */ + +import postgres from 'postgres'; +import { encryptField, hashForLookup } from '../server-crypto'; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + console.error('DATABASE_URL required'); + process.exit(1); +} + +const sql = postgres(DATABASE_URL, { max: 5 }); + +async function backfillUsers() { + const rows = await sql` + SELECT id, email, profile_email, bio, avatar_url, wallet_address, up_address + FROM users WHERE email_enc IS NULL AND (email IS NOT NULL OR bio IS NOT NULL OR avatar_url IS NOT NULL OR wallet_address IS NOT NULL OR up_address IS NOT NULL) + `; + console.log(`[users] ${rows.length} rows to backfill`); + for (const row of rows) { + const [emailEnc, emailHash, profileEmailEnc, bioEnc, avatarUrlEnc, walletEnc, upEnc, upHash] = await Promise.all([ + encryptField(row.email), + row.email ? hashForLookup(row.email) : null, + encryptField(row.profile_email), + encryptField(row.bio), + encryptField(row.avatar_url), + encryptField(row.wallet_address), + encryptField(row.up_address), + row.up_address ? hashForLookup(row.up_address) : null, + ]); + await sql` + UPDATE users SET + email_enc = ${emailEnc}, email_hash = ${emailHash}, + profile_email_enc = ${profileEmailEnc}, + bio_enc = ${bioEnc}, avatar_url_enc = ${avatarUrlEnc}, + wallet_address_enc = ${walletEnc}, + up_address_enc = ${upEnc}, up_address_hash = ${upHash} + WHERE id = ${row.id} + `; + } + console.log(`[users] done`); +} + +async function backfillGuardians() { + const rows = await sql` + SELECT id, name, email FROM guardians WHERE name_enc IS NULL + `; + console.log(`[guardians] ${rows.length} rows to backfill`); + for (const row of rows) { + const [nameEnc, emailEnc] = await Promise.all([ + encryptField(row.name), + encryptField(row.email), + ]); + await sql`UPDATE guardians SET name_enc = ${nameEnc}, email_enc = ${emailEnc} WHERE id = ${row.id}`; + } + console.log(`[guardians] done`); +} + +async function backfillIdentityInvites() { + const rows = await sql` + SELECT id, email, message FROM identity_invites WHERE email_enc IS NULL + `; + console.log(`[identity_invites] ${rows.length} rows to backfill`); + for (const row of rows) { + const [emailEnc, emailHash, messageEnc] = await Promise.all([ + encryptField(row.email), + hashForLookup(row.email), + encryptField(row.message), + ]); + await sql`UPDATE identity_invites SET email_enc = ${emailEnc}, email_hash = ${emailHash}, message_enc = ${messageEnc} WHERE id = ${row.id}`; + } + console.log(`[identity_invites] done`); +} + +async function backfillSpaceInvites() { + const rows = await sql` + SELECT id, email FROM space_invites WHERE email_enc IS NULL AND email IS NOT NULL + `; + console.log(`[space_invites] ${rows.length} rows to backfill`); + for (const row of rows) { + const emailEnc = await encryptField(row.email); + await sql`UPDATE space_invites SET email_enc = ${emailEnc} WHERE id = ${row.id}`; + } + console.log(`[space_invites] done`); +} + +async function backfillNotifications() { + const BATCH = 500; + let offset = 0; + let total = 0; + while (true) { + const rows = await sql` + SELECT id, title, body, actor_username FROM notifications WHERE title_enc IS NULL + ORDER BY created_at LIMIT ${BATCH} OFFSET ${offset} + `; + if (rows.length === 0) break; + total += rows.length; + console.log(`[notifications] processing batch at offset ${offset} (${rows.length} rows)`); + await Promise.all(rows.map(async (row) => { + const [titleEnc, bodyEnc, actorEnc] = await Promise.all([ + encryptField(row.title), + encryptField(row.body), + encryptField(row.actor_username), + ]); + await sql`UPDATE notifications SET title_enc = ${titleEnc}, body_enc = ${bodyEnc}, actor_username_enc = ${actorEnc} WHERE id = ${row.id}`; + })); + offset += BATCH; + } + console.log(`[notifications] ${total} rows done`); +} + +async function backfillFundClaims() { + const rows = await sql` + SELECT id, email, wallet_address FROM fund_claims WHERE email_enc IS NULL AND (email IS NOT NULL OR wallet_address IS NOT NULL) + `; + console.log(`[fund_claims] ${rows.length} rows to backfill`); + for (const row of rows) { + const [emailEnc, emailHmac, walletEnc] = await Promise.all([ + encryptField(row.email), + row.email ? hashForLookup(row.email) : null, + encryptField(row.wallet_address), + ]); + await sql`UPDATE fund_claims SET email_enc = ${emailEnc}, email_hmac = ${emailHmac}, wallet_address_enc = ${walletEnc} WHERE id = ${row.id}`; + } + console.log(`[fund_claims] done`); +} + +async function main() { + console.log('=== PII Encryption Backfill ==='); + console.log(`Time: ${new Date().toISOString()}`); + + await backfillUsers(); + await backfillGuardians(); + await backfillIdentityInvites(); + await backfillSpaceInvites(); + await backfillNotifications(); + await backfillFundClaims(); + + // Verification counts + const [usersLeft] = await sql`SELECT COUNT(*)::int as c FROM users WHERE email IS NOT NULL AND email_enc IS NULL`; + const [guardiansLeft] = await sql`SELECT COUNT(*)::int as c FROM guardians WHERE name_enc IS NULL`; + const [notifLeft] = await sql`SELECT COUNT(*)::int as c FROM notifications WHERE title_enc IS NULL`; + console.log('\n=== Verification ==='); + console.log(`Users without email_enc: ${usersLeft.c}`); + console.log(`Guardians without name_enc: ${guardiansLeft.c}`); + console.log(`Notifications without title_enc: ${notifLeft.c}`); + + await sql.end(); + console.log('\nDone.'); +} + +main().catch(err => { + console.error('Migration failed:', err); + process.exit(1); +}); diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index 024dd1e..84b3261 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.sql @@ -506,3 +506,45 @@ CREATE TABLE IF NOT EXISTS user_tab_state ( ); CREATE INDEX IF NOT EXISTS idx_user_tab_state_user_id ON user_tab_state(user_id); + +-- ============================================================================ +-- PII ENCRYPTION AT REST (server-side AES-256-GCM) +-- _enc columns hold "iv.ciphertext" (base64url); _hash columns hold HMAC-SHA256 hex +-- During migration, both plaintext and _enc columns coexist. +-- ============================================================================ + +-- users: encrypt email, profile_email, bio, avatar_url, wallet_address, up_address +ALTER TABLE users ADD COLUMN IF NOT EXISTS email_enc TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS email_hash TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_email_enc TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS bio_enc TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url_enc TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS wallet_address_enc TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS up_address_enc TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS up_address_hash TEXT; +CREATE INDEX IF NOT EXISTS idx_users_email_hash ON users(email_hash); +CREATE INDEX IF NOT EXISTS idx_users_up_address_hash ON users(up_address_hash); + +-- guardians: encrypt name, email +ALTER TABLE guardians ADD COLUMN IF NOT EXISTS name_enc TEXT; +ALTER TABLE guardians ADD COLUMN IF NOT EXISTS email_enc TEXT; + +-- identity_invites: encrypt email, message +ALTER TABLE identity_invites ADD COLUMN IF NOT EXISTS email_enc TEXT; +ALTER TABLE identity_invites ADD COLUMN IF NOT EXISTS email_hash TEXT; +ALTER TABLE identity_invites ADD COLUMN IF NOT EXISTS message_enc TEXT; +CREATE INDEX IF NOT EXISTS idx_identity_invites_email_hash ON identity_invites(email_hash); + +-- space_invites: encrypt email +ALTER TABLE space_invites ADD COLUMN IF NOT EXISTS email_enc TEXT; + +-- notifications: encrypt title, body, actor_username +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS title_enc TEXT; +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS body_enc TEXT; +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS actor_username_enc TEXT; + +-- fund_claims: encrypt email, wallet_address; add HMAC email_hash column +ALTER TABLE fund_claims ADD COLUMN IF NOT EXISTS email_enc TEXT; +ALTER TABLE fund_claims ADD COLUMN IF NOT EXISTS email_hmac TEXT; +ALTER TABLE fund_claims ADD COLUMN IF NOT EXISTS wallet_address_enc TEXT; +CREATE INDEX IF NOT EXISTS idx_fund_claims_email_hmac ON fund_claims(email_hmac); diff --git a/src/encryptid/server-crypto.ts b/src/encryptid/server-crypto.ts new file mode 100644 index 0000000..59d8209 --- /dev/null +++ b/src/encryptid/server-crypto.ts @@ -0,0 +1,89 @@ +/** + * Server-side PII encryption for EncryptID + * + * AES-256-GCM encryption and HMAC-SHA256 hashing derived from JWT_SECRET via HKDF. + * Used to encrypt PII at rest in PostgreSQL and provide deterministic hashes for lookups. + */ + +const JWT_SECRET = process.env.JWT_SECRET; +if (!JWT_SECRET) throw new Error('JWT_SECRET environment variable is required'); + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +// ── Cached keys ── + +let _piiKey: CryptoKey | null = null; +let _hmacKey: CryptoKey | null = null; + +async function getPIIKey(): Promise { + if (_piiKey) return _piiKey; + const base = await crypto.subtle.importKey('raw', encoder.encode(JWT_SECRET), { name: 'HKDF' }, false, ['deriveKey']); + _piiKey = await crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: encoder.encode('pii-v1'), info: new Uint8Array(0) }, + base, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); + return _piiKey; +} + +async function getHMACKey(): Promise { + if (_hmacKey) return _hmacKey; + const base = await crypto.subtle.importKey('raw', encoder.encode(JWT_SECRET), { name: 'HKDF' }, false, ['deriveKey']); + _hmacKey = await crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: encoder.encode('pii-hash-v1'), info: new Uint8Array(0) }, + base, + { name: 'HMAC', hash: 'SHA-256', length: 256 }, + false, + ['sign'], + ); + return _hmacKey; +} + +// ── Public API ── + +/** + * Encrypt a PII field for storage. Returns "iv.ciphertext" (base64url), or null if input is null/undefined. + */ +export async function encryptField(plaintext: string | null | undefined): Promise { + if (plaintext == null || plaintext === '') return null; + const key = await getPIIKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = new Uint8Array(await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encoder.encode(plaintext), + )); + return `${Buffer.from(iv).toString('base64url')}.${Buffer.from(ct).toString('base64url')}`; +} + +/** + * Decrypt a stored PII field. Returns plaintext, or null if input is null. + * Falls back to returning the value as-is if it doesn't contain a "." separator (legacy plaintext). + */ +export async function decryptField(stored: string | null | undefined): Promise { + if (stored == null || stored === '') return null; + const dotIdx = stored.indexOf('.'); + if (dotIdx === -1) return stored; // legacy plaintext passthrough + const key = await getPIIKey(); + const iv = Buffer.from(stored.slice(0, dotIdx), 'base64url'); + const ct = Buffer.from(stored.slice(dotIdx + 1), 'base64url'); + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ct, + ); + return decoder.decode(plain); +} + +/** + * Compute a deterministic HMAC-SHA256 hash for equality lookups. + * Input is lowercased and trimmed before hashing. + */ +export async function hashForLookup(value: string): Promise { + const key = await getHMACKey(); + const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(value.toLowerCase().trim())); + return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index a608363..9718ae9 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -425,11 +425,8 @@ async function sendClaimEmail(to: string, token: string, amount?: string, curren return true; } -async function hashEmail(email: string): Promise { - const data = new TextEncoder().encode(email.toLowerCase().trim()); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join(''); -} +// PII hashing (keyed HMAC-SHA256 via server-crypto) +import { hashForLookup } from './server-crypto'; // ============================================================================ // HONO APP @@ -1021,7 +1018,7 @@ app.get('/api/user/claims', async (c) => { const profile = await getUserProfile(claims.sub); if (!profile?.profileEmail) return c.json({ claims: [] }); - const emailHashed = await hashEmail(profile.profileEmail); + const emailHashed = await hashForLookup(profile.profileEmail); const pendingClaims = await getFundClaimsByEmailHash(emailHashed); return c.json({ claims: pendingClaims.map(cl => ({ @@ -4993,7 +4990,7 @@ app.post('/api/internal/fund-claims', async (c) => { return c.json({ error: 'email and walletAddress are required' }, 400); } - const emailHashed = await hashEmail(email); + const emailHashed = await hashForLookup(email); const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days // Check for existing pending claim — accumulate deposits @@ -5093,7 +5090,7 @@ app.post('/api/claims/resend', async (c) => { const { email } = await c.req.json(); if (!email) return c.json({ ok: true }); - const emailHashed = await hashEmail(email); + const emailHashed = await hashForLookup(email); const pendingClaims = await getFundClaimsByEmailHash(emailHashed); if (pendingClaims.length > 0) {