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 { readFileSync } from 'fs';
import { join } from 'path';
import { encryptField, decryptField, hashForLookup } from './server-crypto';
// ============================================================================
// CONNECTION
@ -224,12 +225,23 @@ export async function cleanExpiredChallenges(): Promise<number> {
// ============================================================================
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) {
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<StoredGuardian> {
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<StoredGuardian> {
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<StoredGuardian[]> {
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<StoredGuardian | null> {
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<void> {
@ -477,7 +494,7 @@ export async function removeGuardian(guardianId: string, userId: string): Promis
export async function getGuardianById(guardianId: string): Promise<StoredGuardian | null> {
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<StoredGuardian[]> {
@ -486,7 +503,7 @@ export async function getGuardianships(guardianUserId: string): Promise<StoredGu
WHERE guardian_user_id = ${guardianUserId} AND status = 'accepted'
ORDER BY created_at ASC
`;
return rows.map(rowToGuardian);
return Promise.all(rows.map(rowToGuardian));
}
// ============================================================================
@ -646,17 +663,23 @@ export interface StoredUserProfile {
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 {
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<StoredUserProfile | null> {
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<AdminUserInfo[]> {
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<StoredSpaceInvite> {
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<StoredSpaceInvite> {
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<StoredSpaceInvite | null> {
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<StoredSpaceInvite[]> {
@ -1020,7 +1061,7 @@ export async function listSpaceInvites(spaceSlug: string): Promise<StoredSpaceIn
WHERE space_slug = ${spaceSlug}
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> {
@ -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<boolean> {
@ -1069,19 +1110,24 @@ export interface StoredNotification {
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 {
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<string, any>;
expiresAt?: Date;
}): Promise<StoredNotification> {
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<number> {
@ -1321,13 +1375,17 @@ export interface StoredFundClaim {
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 {
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<StoredFundClaim> {
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<StoredFundClaim | null> {
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<StoredFundClaim[]> {
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<StoredFundClaim | null> {
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<StoredFundClaim | null> {
@ -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<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> {
// 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<StoredIdentityInvite> {
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<StoredIdentityInvite> {
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<StoredIdentityInvite | null> {
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[]> {
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<StoredIdentityInvite[]> {
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[]> {
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> {
@ -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<boolean> {
@ -2191,12 +2279,13 @@ export interface StoredUniversalProfile {
export async function getUserUPAddress(userId: string): Promise<StoredUniversalProfile | null> {
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<void> {
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 };

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);
-- ============================================================================
-- 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;
}
async function hashEmail(email: string): Promise<string> {
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) {