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 { 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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
-- ============================================================================
|
||||
-- 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;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue