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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-23 16:50:21 -07:00
parent 83c729f3df
commit 9695e9577a
5 changed files with 477 additions and 90 deletions

View File

@ -8,6 +8,7 @@
import postgres from 'postgres'; import postgres from 'postgres';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { encryptField, decryptField, hashForLookup } from './server-crypto';
// ============================================================================ // ============================================================================
// CONNECTION // CONNECTION
@ -224,12 +225,23 @@ export async function cleanExpiredChallenges(): Promise<number> {
// ============================================================================ // ============================================================================
export async function setUserEmail(userId: string, email: string): Promise<void> { export async function setUserEmail(userId: string, email: string): Promise<void> {
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) { export async function getUserByEmail(email: string) {
const [user] = await sql`SELECT * FROM users WHERE email = ${email}`; const hash = await hashForLookup(email);
return user || null; // 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) { export async function getUserById(userId: string) {
@ -412,12 +424,16 @@ export interface StoredGuardian {
createdAt: number; createdAt: number;
} }
function rowToGuardian(row: any): StoredGuardian { async function rowToGuardian(row: any): Promise<StoredGuardian> {
const [nameDecrypted, emailDecrypted] = await Promise.all([
decryptField(row.name_enc),
decryptField(row.email_enc),
]);
return { return {
id: row.id, id: row.id,
userId: row.user_id, userId: row.user_id,
name: row.name, name: nameDecrypted ?? row.name,
email: row.email || null, email: emailDecrypted ?? row.email ?? null,
guardianUserId: row.guardian_user_id || null, guardianUserId: row.guardian_user_id || null,
status: row.status, status: row.status,
inviteToken: row.invite_token || null, inviteToken: row.invite_token || null,
@ -435,9 +451,10 @@ export async function addGuardian(
inviteToken: string, inviteToken: string,
inviteExpiresAt: number, inviteExpiresAt: number,
): Promise<StoredGuardian> { ): Promise<StoredGuardian> {
const [nameEnc, emailEnc] = await Promise.all([encryptField(name), encryptField(email)]);
const rows = await sql` const rows = await sql`
INSERT INTO guardians (id, user_id, name, email, invite_token, invite_expires_at) INSERT INTO guardians (id, user_id, name, email, name_enc, email_enc, invite_token, invite_expires_at)
VALUES (${id}, ${userId}, ${name}, ${email}, ${inviteToken}, ${new Date(inviteExpiresAt)}) VALUES (${id}, ${userId}, ${name}, ${email}, ${nameEnc}, ${emailEnc}, ${inviteToken}, ${new Date(inviteExpiresAt)})
RETURNING * RETURNING *
`; `;
return rowToGuardian(rows[0]); return rowToGuardian(rows[0]);
@ -449,13 +466,13 @@ export async function getGuardians(userId: string): Promise<StoredGuardian[]> {
WHERE user_id = ${userId} AND status != 'revoked' WHERE user_id = ${userId} AND status != 'revoked'
ORDER BY created_at ASC ORDER BY created_at ASC
`; `;
return rows.map(rowToGuardian); return Promise.all(rows.map(rowToGuardian));
} }
export async function getGuardianByInviteToken(token: string): Promise<StoredGuardian | null> { export async function getGuardianByInviteToken(token: string): Promise<StoredGuardian | null> {
const rows = await sql`SELECT * FROM guardians WHERE invite_token = ${token}`; const rows = await sql`SELECT * FROM guardians WHERE invite_token = ${token}`;
if (rows.length === 0) return null; if (rows.length === 0) return null;
return rowToGuardian(rows[0]); return await rowToGuardian(rows[0]);
} }
export async function acceptGuardianInvite(guardianId: string, guardianUserId: string): Promise<void> { export async function acceptGuardianInvite(guardianId: string, guardianUserId: string): Promise<void> {
@ -477,7 +494,7 @@ export async function removeGuardian(guardianId: string, userId: string): Promis
export async function getGuardianById(guardianId: string): Promise<StoredGuardian | null> { export async function getGuardianById(guardianId: string): Promise<StoredGuardian | null> {
const rows = await sql`SELECT * FROM guardians WHERE id = ${guardianId}`; const rows = await sql`SELECT * FROM guardians WHERE id = ${guardianId}`;
if (rows.length === 0) return null; if (rows.length === 0) return null;
return rowToGuardian(rows[0]); return await rowToGuardian(rows[0]);
} }
export async function getGuardianships(guardianUserId: string): Promise<StoredGuardian[]> { export async function getGuardianships(guardianUserId: string): Promise<StoredGuardian[]> {
@ -486,7 +503,7 @@ export async function getGuardianships(guardianUserId: string): Promise<StoredGu
WHERE guardian_user_id = ${guardianUserId} AND status = 'accepted' WHERE guardian_user_id = ${guardianUserId} AND status = 'accepted'
ORDER BY created_at ASC ORDER BY created_at ASC
`; `;
return rows.map(rowToGuardian); return Promise.all(rows.map(rowToGuardian));
} }
// ============================================================================ // ============================================================================
@ -646,17 +663,23 @@ export interface StoredUserProfile {
updatedAt: string; updatedAt: string;
} }
function rowToProfile(row: any): StoredUserProfile { async function rowToProfile(row: any): Promise<StoredUserProfile> {
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 { return {
userId: row.id, userId: row.id,
username: row.username, username: row.username,
displayName: row.display_name || null, displayName: row.display_name || null,
bio: row.bio || null, bio: bioDecrypted ?? row.bio ?? null,
avatarUrl: row.avatar_url || null, avatarUrl: avatarUrlDecrypted ?? row.avatar_url ?? null,
profileEmail: row.profile_email || null, profileEmail: profileEmailDecrypted ?? row.profile_email ?? null,
profileEmailIsRecovery: row.profile_email_is_recovery || false, profileEmailIsRecovery: row.profile_email_is_recovery || false,
did: row.did || null, did: row.did || null,
walletAddress: row.wallet_address || null, walletAddress: walletDecrypted ?? row.wallet_address ?? null,
emailForwardEnabled: row.email_forward_enabled || false, emailForwardEnabled: row.email_forward_enabled || false,
emailForwardMailcowId: row.email_forward_mailcow_id || null, emailForwardMailcowId: row.email_forward_mailcow_id || null,
createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), 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<StoredUserProfile | null> { export async function getUserProfile(userId: string): Promise<StoredUserProfile | null> {
const [row] = await sql`SELECT * FROM users WHERE id = ${userId}`; const [row] = await sql`SELECT * FROM users WHERE id = ${userId}`;
if (!row) return null; if (!row) return null;
return rowToProfile(row); return await rowToProfile(row);
} }
export interface UserProfileUpdates { export interface UserProfileUpdates {
@ -684,11 +707,23 @@ export async function updateUserProfile(userId: string, updates: UserProfileUpda
const values: any[] = []; const values: any[] = [];
if (updates.displayName !== undefined) { sets.push('display_name'); values.push(updates.displayName); } 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.bio !== undefined) {
if (updates.avatarUrl !== undefined) { sets.push('avatar_url'); values.push(updates.avatarUrl); } sets.push('bio'); values.push(updates.bio);
if (updates.profileEmail !== undefined) { sets.push('profile_email'); values.push(updates.profileEmail); } 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.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) { if (sets.length === 0) {
return getUserProfile(userId); return getUserProfile(userId);
@ -870,15 +905,16 @@ export async function getEmailForwardStatus(userId: string): Promise<{
profileEmail: string | null; profileEmail: string | null;
} | null> { } | null> {
const [row] = await sql` 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} FROM users WHERE id = ${userId}
`; `;
if (!row) return null; if (!row) return null;
const profileEmailDecrypted = await decryptField(row.profile_email_enc);
return { return {
enabled: row.email_forward_enabled || false, enabled: row.email_forward_enabled || false,
mailcowId: row.email_forward_mailcow_id || null, mailcowId: row.email_forward_mailcow_id || null,
username: row.username, 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<AdminUserInfo[]> { export async function listAllUsers(): Promise<AdminUserInfo[]> {
const rows = await sql` 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 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 (SELECT COUNT(*)::int FROM space_members sm WHERE sm.user_did = u.did) as space_membership_count
FROM users u FROM users u
ORDER BY u.created_at DESC ORDER BY u.created_at DESC
`; `;
return rows.map(row => ({ return Promise.all(rows.map(async row => {
userId: row.id, const emailDecrypted = await decryptField(row.email_enc);
username: row.username, return {
displayName: row.display_name || null, userId: row.id,
did: row.did || null, username: row.username,
email: row.email || null, displayName: row.display_name || null,
createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), did: row.did || null,
credentialCount: Number(row.credential_count), email: emailDecrypted ?? row.email ?? null,
spaceMembershipCount: Number(row.space_membership_count), 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; acceptedByDid: string | null;
} }
function rowToInvite(row: any): StoredSpaceInvite { async function rowToInvite(row: any): Promise<StoredSpaceInvite> {
const emailDecrypted = await decryptField(row.email_enc);
return { return {
id: row.id, id: row.id,
spaceSlug: row.space_slug, spaceSlug: row.space_slug,
email: row.email || null, email: emailDecrypted ?? row.email ?? null,
role: row.role, role: row.role,
token: row.token, token: row.token,
invitedBy: row.invited_by, invitedBy: row.invited_by,
@ -1000,18 +1040,19 @@ export async function createSpaceInvite(
expiresAt: number, expiresAt: number,
email?: string, email?: string,
): Promise<StoredSpaceInvite> { ): Promise<StoredSpaceInvite> {
const emailEnc = await encryptField(email || null);
const rows = await sql` const rows = await sql`
INSERT INTO space_invites (id, space_slug, email, role, token, invited_by, expires_at) INSERT INTO space_invites (id, space_slug, email, email_enc, role, token, invited_by, expires_at)
VALUES (${id}, ${spaceSlug}, ${email || null}, ${role}, ${token}, ${invitedBy}, ${new Date(expiresAt)}) VALUES (${id}, ${spaceSlug}, ${email || null}, ${emailEnc}, ${role}, ${token}, ${invitedBy}, ${new Date(expiresAt)})
RETURNING * RETURNING *
`; `;
return rowToInvite(rows[0]); return await rowToInvite(rows[0]);
} }
export async function getSpaceInviteByToken(token: string): Promise<StoredSpaceInvite | null> { export async function getSpaceInviteByToken(token: string): Promise<StoredSpaceInvite | null> {
const rows = await sql`SELECT * FROM space_invites WHERE token = ${token}`; const rows = await sql`SELECT * FROM space_invites WHERE token = ${token}`;
if (rows.length === 0) return null; if (rows.length === 0) return null;
return rowToInvite(rows[0]); return await rowToInvite(rows[0]);
} }
export async function listSpaceInvites(spaceSlug: string): Promise<StoredSpaceInvite[]> { export async function listSpaceInvites(spaceSlug: string): Promise<StoredSpaceInvite[]> {
@ -1020,7 +1061,7 @@ export async function listSpaceInvites(spaceSlug: string): Promise<StoredSpaceIn
WHERE space_slug = ${spaceSlug} WHERE space_slug = ${spaceSlug}
ORDER BY created_at DESC ORDER BY created_at DESC
`; `;
return rows.map(rowToInvite); return Promise.all(rows.map(rowToInvite));
} }
export async function acceptSpaceInvite(token: string, acceptedByDid: string): Promise<StoredSpaceInvite | null> { export async function acceptSpaceInvite(token: string, acceptedByDid: string): Promise<StoredSpaceInvite | null> {
@ -1031,7 +1072,7 @@ export async function acceptSpaceInvite(token: string, acceptedByDid: string): P
RETURNING * RETURNING *
`; `;
if (rows.length === 0) return null; if (rows.length === 0) return null;
return rowToInvite(rows[0]); return await rowToInvite(rows[0]);
} }
export async function revokeSpaceInvite(id: string, spaceSlug: string): Promise<boolean> { export async function revokeSpaceInvite(id: string, spaceSlug: string): Promise<boolean> {
@ -1069,19 +1110,24 @@ export interface StoredNotification {
expiresAt: string | null; expiresAt: string | null;
} }
function rowToNotification(row: any): StoredNotification { async function rowToNotification(row: any): Promise<StoredNotification> {
const [titleDecrypted, bodyDecrypted, actorUsernameDecrypted] = await Promise.all([
decryptField(row.title_enc),
decryptField(row.body_enc),
decryptField(row.actor_username_enc),
]);
return { return {
id: row.id, id: row.id,
userDid: row.user_did, userDid: row.user_did,
category: row.category, category: row.category,
eventType: row.event_type, eventType: row.event_type,
title: row.title, title: titleDecrypted ?? row.title,
body: row.body || null, body: bodyDecrypted ?? row.body ?? null,
spaceSlug: row.space_slug || null, spaceSlug: row.space_slug || null,
moduleId: row.module_id || null, moduleId: row.module_id || null,
actionUrl: row.action_url || null, actionUrl: row.action_url || null,
actorDid: row.actor_did || null, actorDid: row.actor_did || null,
actorUsername: row.actor_username || null, actorUsername: actorUsernameDecrypted ?? row.actor_username ?? null,
metadata: row.metadata || {}, metadata: row.metadata || {},
read: row.read, read: row.read,
dismissed: row.dismissed, dismissed: row.dismissed,
@ -1109,8 +1155,13 @@ export async function createNotification(notif: {
metadata?: Record<string, any>; metadata?: Record<string, any>;
expiresAt?: Date; expiresAt?: Date;
}): Promise<StoredNotification> { }): Promise<StoredNotification> {
const [titleEnc, bodyEnc, actorUsernameEnc] = await Promise.all([
encryptField(notif.title),
encryptField(notif.body || null),
encryptField(notif.actorUsername || null),
]);
const rows = await sql` 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 ( VALUES (
${notif.id}, ${notif.id},
${notif.userDid}, ${notif.userDid},
@ -1118,6 +1169,9 @@ export async function createNotification(notif: {
${notif.eventType}, ${notif.eventType},
${notif.title}, ${notif.title},
${notif.body || null}, ${notif.body || null},
${titleEnc},
${bodyEnc},
${actorUsernameEnc},
${notif.spaceSlug || null}, ${notif.spaceSlug || null},
${notif.moduleId || null}, ${notif.moduleId || null},
${notif.actionUrl || null}, ${notif.actionUrl || null},
@ -1128,7 +1182,7 @@ export async function createNotification(notif: {
) )
RETURNING * RETURNING *
`; `;
return rowToNotification(rows[0]); return await rowToNotification(rows[0]);
} }
export async function getUserNotifications( export async function getUserNotifications(
@ -1164,7 +1218,7 @@ export async function getUserNotifications(
ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset} 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<number> { export async function getUnreadCount(userDid: string): Promise<number> {
@ -1321,13 +1375,17 @@ export interface StoredFundClaim {
claimedAt: number | null; claimedAt: number | null;
} }
function rowToFundClaim(row: any): StoredFundClaim { async function rowToFundClaim(row: any): Promise<StoredFundClaim> {
const [emailDecrypted, walletDecrypted] = await Promise.all([
decryptField(row.email_enc),
decryptField(row.wallet_address_enc),
]);
return { return {
id: row.id, id: row.id,
token: row.token, token: row.token,
emailHash: row.email_hash, emailHash: row.email_hmac || row.email_hash,
email: row.email || null, email: emailDecrypted ?? row.email ?? null,
walletAddress: row.wallet_address, walletAddress: walletDecrypted ?? row.wallet_address,
openfortPlayerId: row.openfort_player_id || null, openfortPlayerId: row.openfort_player_id || null,
fiatAmount: row.fiat_amount || null, fiatAmount: row.fiat_amount || null,
fiatCurrency: row.fiat_currency || 'USD', fiatCurrency: row.fiat_currency || 'USD',
@ -1354,14 +1412,22 @@ export async function createFundClaim(claim: {
provider?: string; provider?: string;
expiresAt: number; expiresAt: number;
}): Promise<StoredFundClaim> { }): Promise<StoredFundClaim> {
const [emailEnc, walletEnc, emailHmac] = await Promise.all([
encryptField(claim.email),
encryptField(claim.walletAddress),
hashForLookup(claim.email),
]);
const rows = await sql` 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 ( VALUES (
${claim.id}, ${claim.id},
${claim.token}, ${claim.token},
${claim.emailHash}, ${claim.emailHash},
${claim.email}, ${claim.email},
${emailEnc},
${emailHmac},
${claim.walletAddress}, ${claim.walletAddress},
${walletEnc},
${claim.openfortPlayerId || null}, ${claim.openfortPlayerId || null},
${claim.fiatAmount || null}, ${claim.fiatAmount || null},
${claim.fiatCurrency || 'USD'}, ${claim.fiatCurrency || 'USD'},
@ -1371,33 +1437,41 @@ export async function createFundClaim(claim: {
) )
RETURNING * RETURNING *
`; `;
return rowToFundClaim(rows[0]); return await rowToFundClaim(rows[0]);
} }
export async function getFundClaimByToken(token: string): Promise<StoredFundClaim | null> { export async function getFundClaimByToken(token: string): Promise<StoredFundClaim | null> {
const rows = await sql`SELECT * FROM fund_claims WHERE token = ${token}`; const rows = await sql`SELECT * FROM fund_claims WHERE token = ${token}`;
if (rows.length === 0) return null; if (rows.length === 0) return null;
return rowToFundClaim(rows[0]); return await rowToFundClaim(rows[0]);
} }
export async function getFundClaimsByEmailHash(emailHash: string): Promise<StoredFundClaim[]> { export async function getFundClaimsByEmailHash(emailHash: string): Promise<StoredFundClaim[]> {
const rows = await sql` // Try HMAC hash first, fall back to legacy SHA-256 hash
let rows = await sql`
SELECT * FROM fund_claims 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 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<StoredFundClaim | null> { export async function acceptFundClaim(token: string, userId: string): Promise<StoredFundClaim | null> {
const rows = await sql` const rows = await sql`
UPDATE fund_claims 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() WHERE token = ${token} AND status IN ('pending', 'resent') AND expires_at > NOW()
RETURNING * RETURNING *
`; `;
if (rows.length === 0) return null; 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<StoredFundClaim | null> { export async function accumulateFundClaim(claimId: string, additionalAmount: string, expiresAt: number): Promise<StoredFundClaim | null> {
@ -1409,16 +1483,16 @@ export async function accumulateFundClaim(claimId: string, additionalAmount: str
RETURNING * RETURNING *
`; `;
if (rows.length === 0) return null; if (rows.length === 0) return null;
return rowToFundClaim(rows[0]); return await rowToFundClaim(rows[0]);
} }
export async function expireFundClaim(claimId: string): Promise<void> { export async function expireFundClaim(claimId: string): Promise<void> {
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<number> { export async function cleanExpiredFundClaims(): Promise<number> {
// Null out email on expired claims, then mark them expired // 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'`; const result = await sql`DELETE FROM fund_claims WHERE status = 'expired' AND expires_at < NOW() - INTERVAL '30 days'`;
return result.count; return result.count;
} }
@ -1580,14 +1654,18 @@ export interface StoredIdentityInvite {
claimedAt: number | null; claimedAt: number | null;
} }
function mapInviteRow(r: any): StoredIdentityInvite { async function mapInviteRow(r: any): Promise<StoredIdentityInvite> {
const [emailDecrypted, messageDecrypted] = await Promise.all([
decryptField(r.email_enc),
decryptField(r.message_enc),
]);
return { return {
id: r.id, id: r.id,
token: r.token, token: r.token,
email: r.email, email: emailDecrypted ?? r.email,
invitedByUserId: r.invited_by_user_id, invitedByUserId: r.invited_by_user_id,
invitedByUsername: r.invited_by_username, invitedByUsername: r.invited_by_username,
message: r.message, message: messageDecrypted ?? r.message ?? null,
spaceSlug: r.space_slug, spaceSlug: r.space_slug,
spaceRole: r.space_role, spaceRole: r.space_role,
clientId: r.client_id || null, clientId: r.client_id || null,
@ -1611,36 +1689,46 @@ export async function createIdentityInvite(invite: {
clientId?: string; clientId?: string;
expiresAt: number; expiresAt: number;
}): Promise<StoredIdentityInvite> { }): Promise<StoredIdentityInvite> {
const [emailEnc, emailHash, messageEnc] = await Promise.all([
encryptField(invite.email),
hashForLookup(invite.email),
encryptField(invite.message || null),
]);
const rows = await sql` 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) 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}, ${invite.invitedByUserId}, VALUES (${invite.id}, ${invite.token}, ${invite.email}, ${emailEnc}, ${emailHash}, ${invite.invitedByUserId},
${invite.invitedByUsername}, ${invite.message || null}, ${invite.invitedByUsername}, ${invite.message || null}, ${messageEnc},
${invite.spaceSlug || null}, ${invite.spaceRole || 'member'}, ${invite.spaceSlug || null}, ${invite.spaceRole || 'member'},
${invite.clientId || null}, ${invite.clientId || null},
${new Date(invite.expiresAt).toISOString()}) ${new Date(invite.expiresAt).toISOString()})
RETURNING * RETURNING *
`; `;
return mapInviteRow(rows[0]); return await mapInviteRow(rows[0]);
} }
export async function getIdentityInviteByToken(token: string): Promise<StoredIdentityInvite | null> { export async function getIdentityInviteByToken(token: string): Promise<StoredIdentityInvite | null> {
const rows = await sql`SELECT * FROM identity_invites WHERE token = ${token}`; 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<StoredIdentityInvite[]> { export async function getIdentityInvitesByEmail(email: string): Promise<StoredIdentityInvite[]> {
const rows = await sql`SELECT * FROM identity_invites WHERE email = ${email} ORDER BY created_at DESC`; const hash = await hashForLookup(email);
return rows.map(mapInviteRow); // 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<StoredIdentityInvite[]> { export async function getIdentityInvitesByInviter(userId: string): Promise<StoredIdentityInvite[]> {
const rows = await sql`SELECT * FROM identity_invites WHERE invited_by_user_id = ${userId} ORDER BY created_at DESC`; 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<StoredIdentityInvite[]> { export async function getIdentityInvitesByClient(clientId: string): Promise<StoredIdentityInvite[]> {
const rows = await sql`SELECT * FROM identity_invites WHERE client_id = ${clientId} ORDER BY created_at DESC`; 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<StoredIdentityInvite | null> { export async function claimIdentityInvite(token: string, claimedByUserId: string): Promise<StoredIdentityInvite | null> {
@ -1650,7 +1738,7 @@ export async function claimIdentityInvite(token: string, claimedByUserId: string
WHERE token = ${token} AND status = 'pending' AND expires_at > NOW() WHERE token = ${token} AND status = 'pending' AND expires_at > NOW()
RETURNING * 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<boolean> { export async function revokeIdentityInvite(id: string, userId: string): Promise<boolean> {
@ -2191,12 +2279,13 @@ export interface StoredUniversalProfile {
export async function getUserUPAddress(userId: string): Promise<StoredUniversalProfile | null> { export async function getUserUPAddress(userId: string): Promise<StoredUniversalProfile | null> {
const [row] = await sql` 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 FROM users WHERE id = ${userId} AND up_address IS NOT NULL
`; `;
if (!row) return null; if (!row) return null;
const upDecrypted = await decryptField(row.up_address_enc);
return { return {
upAddress: row.up_address, upAddress: upDecrypted ?? row.up_address,
keyManagerAddress: row.up_key_manager_address, keyManagerAddress: row.up_key_manager_address,
chainId: row.up_chain_id, chainId: row.up_chain_id,
deployedAt: new Date(row.up_deployed_at), deployedAt: new Date(row.up_deployed_at),
@ -2209,9 +2298,12 @@ export async function setUserUPAddress(
keyManagerAddress: string, keyManagerAddress: string,
chainId: number, chainId: number,
): Promise<void> { ): Promise<void> {
const [upEnc, upHash] = await Promise.all([encryptField(upAddress), hashForLookup(upAddress)]);
await sql` await sql`
UPDATE users UPDATE users
SET up_address = ${upAddress}, SET up_address = ${upAddress},
up_address_enc = ${upEnc},
up_address_hash = ${upHash},
up_key_manager_address = ${keyManagerAddress}, up_key_manager_address = ${keyManagerAddress},
up_chain_id = ${chainId}, up_chain_id = ${chainId},
up_deployed_at = NOW() up_deployed_at = NOW()
@ -2220,9 +2312,13 @@ export async function setUserUPAddress(
} }
export async function getUserByUPAddress(upAddress: string): Promise<{ userId: string; username: string } | null> { 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}`; const hash = await hashForLookup(upAddress);
if (!row) return null; // Try hash lookup first, fall back to plaintext for pre-migration rows
return { userId: row.id, username: row.username }; 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 }; export { sql };

View File

@ -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);
});

View File

@ -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); 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);

View File

@ -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<CryptoKey> {
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<CryptoKey> {
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<string | null> {
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<string | null> {
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<string> {
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('');
}

View File

@ -425,11 +425,8 @@ async function sendClaimEmail(to: string, token: string, amount?: string, curren
return true; return true;
} }
async function hashEmail(email: string): Promise<string> { // PII hashing (keyed HMAC-SHA256 via server-crypto)
const data = new TextEncoder().encode(email.toLowerCase().trim()); import { hashForLookup } from './server-crypto';
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
}
// ============================================================================ // ============================================================================
// HONO APP // HONO APP
@ -1021,7 +1018,7 @@ app.get('/api/user/claims', async (c) => {
const profile = await getUserProfile(claims.sub); const profile = await getUserProfile(claims.sub);
if (!profile?.profileEmail) return c.json({ claims: [] }); if (!profile?.profileEmail) return c.json({ claims: [] });
const emailHashed = await hashEmail(profile.profileEmail); const emailHashed = await hashForLookup(profile.profileEmail);
const pendingClaims = await getFundClaimsByEmailHash(emailHashed); const pendingClaims = await getFundClaimsByEmailHash(emailHashed);
return c.json({ return c.json({
claims: pendingClaims.map(cl => ({ 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); 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 const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days
// Check for existing pending claim — accumulate deposits // Check for existing pending claim — accumulate deposits
@ -5093,7 +5090,7 @@ app.post('/api/claims/resend', async (c) => {
const { email } = await c.req.json(); const { email } = await c.req.json();
if (!email) return c.json({ ok: true }); if (!email) return c.json({ ok: true });
const emailHashed = await hashEmail(email); const emailHashed = await hashForLookup(email);
const pendingClaims = await getFundClaimsByEmailHash(emailHashed); const pendingClaims = await getFundClaimsByEmailHash(emailHashed);
if (pendingClaims.length > 0) { if (pendingClaims.length > 0) {