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:
parent
83c729f3df
commit
9695e9577a
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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('');
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue