Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ USE_MOCK_MEMBER_API=true
# Email Unsubscribe Token Secret (32 characters minimum)
UNSUBSCRIBE_SECRET=your-32-character-secret-here-change-in-production

# Field-Level Encryption Keys (64-char hex strings, 256-bit)
# Generate with: openssl rand -hex 32
FIELD_ENCRYPTION_KEY=your-64-char-hex-key-here
BLIND_INDEX_KEY=your-64-char-hex-key-here

# Application Base URL (for generating unsubscribe links)
APP_URL=http://localhost:3000

Expand Down
2 changes: 2 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ SMTP_PASS="testpass"
FROM_EMAIL="noreply@test.com"
NODE_ENV="test"
UNSUBSCRIBE_SECRET="test-secret-key-must-be-at-least-32-chars"
FIELD_ENCRYPTION_KEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
BLIND_INDEX_KEY="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
APP_URL="http://localhost:3000"
805 changes: 438 additions & 367 deletions package-lock.json

Large diffs are not rendered by default.

19 changes: 10 additions & 9 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,13 @@ model Referral {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
memberName String @map("member_name") @db.VarChar(255)
memberEmail String @map("member_email") @db.VarChar(255)
prospectName String @map("prospect_name") @db.VarChar(255)
prospectEmail String @map("prospect_email") @db.VarChar(255)
memberName String @map("member_name") @db.VarChar(512)
memberEmail String @map("member_email") @db.VarChar(512)
prospectName String @map("prospect_name") @db.VarChar(512)
prospectEmail String @map("prospect_email") @db.VarChar(512)
referralCode String @map("referral_code") @db.VarChar(100)
redeemed Boolean @default(false)

@@index([memberEmail])
@@index([createdAt])
@@index([referralCode])
@@map("referral")
Expand Down Expand Up @@ -62,18 +61,19 @@ model ContactGroupMember {
model SmsConsent {
id Int @id @default(autoincrement())
memberId Int @map("member_id")
phone String @db.VarChar(20)
phone String @db.VarChar(512)
phoneHash String @map("phone_hash") @db.VarChar(64)
consentedAt DateTime @default(now()) @map("consented_at")
consentMethod String @map("consent_method") @db.VarChar(50)
consentText String @map("consent_text") @db.Text
consentPurpose String @map("consent_purpose") @db.VarChar(100)
ipAddress String? @map("ip_address") @db.VarChar(45)
ipAddress String? @map("ip_address") @db.VarChar(512)
revokedAt DateTime? @map("revoked_at")
revokeMethod String? @map("revoke_method") @db.VarChar(50)
revokeMessage String? @map("revoke_message") @db.VarChar(500)

@@index([memberId])
@@index([phone])
@@index([phoneHash])
@@index([consentedAt])
@@map("sms_consent")
}
Expand All @@ -86,7 +86,8 @@ enum EmailSuppressionReason {

model EmailSuppression {
id Int @id @default(autoincrement())
email String @unique @db.VarChar(255)
email String @db.VarChar(512)
emailHash String @unique @map("email_hash") @db.VarChar(64)
reason EmailSuppressionReason
suppressedAt DateTime @default(now()) @map("suppressed_at")

Expand Down
181 changes: 181 additions & 0 deletions scripts/migrate-encrypt-pii.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { PrismaClient } from "../src/generated/prisma/client";
import crypto from "crypto";

const BATCH_SIZE = 100;

function getRequiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
console.error(`Missing required env var: ${name}`);
process.exit(1);
}
return value;
}

function createAdapter() {
const dbUrl = getRequiredEnv("DATABASE_URL");
const url = new URL(dbUrl);
return new PrismaMariaDb({
host: url.hostname,
port: url.port ? parseInt(url.port, 10) : 3306,
user: url.username,
password: url.password,
database: url.pathname.slice(1),
connectionLimit: 1,
});
}

const encryptionKey = Buffer.from(getRequiredEnv("FIELD_ENCRYPTION_KEY"), "hex");
const blindIndexKey = Buffer.from(getRequiredEnv("BLIND_INDEX_KEY"), "hex");

function encrypt(plaintext: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, encrypted, tag]).toString("base64url");
}

function blindIndex(value: string): string {
return crypto.createHmac("sha256", blindIndexKey).update(value.toLowerCase()).digest("hex");
}

function isEncrypted(value: string): boolean {
try {
const buf = Buffer.from(value, "base64url");
return buf.length >= 28;
} catch {
return false;
}
}

const adapter = createAdapter();
const prisma = new PrismaClient({ adapter });

async function migrateReferrals() {
const total = await prisma.referral.count();
console.log(`Found ${total} referrals to migrate`);

let migrated = 0;
let skipped = 0;

for (let skip = 0; skip < total; skip += BATCH_SIZE) {
const batch = await prisma.referral.findMany({
take: BATCH_SIZE,
skip,
orderBy: { id: "asc" },
});

for (const referral of batch) {
if (isEncrypted(referral.memberName)) {
skipped++;
continue;
}

await prisma.referral.update({
where: { id: referral.id },
data: {
memberName: encrypt(referral.memberName),
memberEmail: encrypt(referral.memberEmail),
prospectName: encrypt(referral.prospectName),
prospectEmail: encrypt(referral.prospectEmail),
},
});
migrated++;
}

console.log(`Processed ${Math.min(skip + BATCH_SIZE, total)}/${total} referrals`);
}

console.log(`Referrals: ${migrated} migrated, ${skipped} skipped (already encrypted)`);
}

async function migrateEmailSuppressions() {
const total = await prisma.emailSuppression.count();
console.log(`Found ${total} email suppressions to migrate`);

let migrated = 0;
let skipped = 0;

for (let skip = 0; skip < total; skip += BATCH_SIZE) {
const batch = await prisma.emailSuppression.findMany({
take: BATCH_SIZE,
skip,
orderBy: { id: "asc" },
});

for (const suppression of batch) {
if (isEncrypted(suppression.email)) {
skipped++;
continue;
}

const normalized = suppression.email.toLowerCase();
await prisma.emailSuppression.update({
where: { id: suppression.id },
data: {
email: encrypt(normalized),
emailHash: blindIndex(normalized),
},
});
migrated++;
}

console.log(`Processed ${Math.min(skip + BATCH_SIZE, total)}/${total} suppressions`);
}

console.log(`Email suppressions: ${migrated} migrated, ${skipped} skipped`);
}

async function migrateSmsConsent() {
const total = await prisma.smsConsent.count();
console.log(`Found ${total} SMS consent records to migrate`);

let migrated = 0;
let skipped = 0;

for (let skip = 0; skip < total; skip += BATCH_SIZE) {
const batch = await prisma.smsConsent.findMany({
take: BATCH_SIZE,
skip,
orderBy: { id: "asc" },
});

for (const consent of batch) {
if (isEncrypted(consent.phone)) {
skipped++;
continue;
}

await prisma.smsConsent.update({
where: { id: consent.id },
data: {
phone: encrypt(consent.phone),
phoneHash: blindIndex(consent.phone),
ipAddress: consent.ipAddress ? encrypt(consent.ipAddress) : null,
},
});
migrated++;
}

console.log(`Processed ${Math.min(skip + BATCH_SIZE, total)}/${total} consent records`);
}

console.log(`SMS consent: ${migrated} migrated, ${skipped} skipped`);
}

async function main() {
console.log("Starting PII encryption migration");
await migrateReferrals();
await migrateEmailSuppressions();
await migrateSmsConsent();
console.log("Migration complete");
}

main()
.catch((e) => {
console.error("Migration failed:", e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
9 changes: 9 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ const envSchema = z.object({
// Unsubscribe token signing secret (256-bit minimum)
UNSUBSCRIBE_SECRET: z.string().min(32),

FIELD_ENCRYPTION_KEY: z
.string()
.length(64)
.regex(/^[0-9a-f]+$/i),
BLIND_INDEX_KEY: z
.string()
.length(64)
.regex(/^[0-9a-f]+$/i),

// Application base URL for generating unsubscribe links
APP_URL: z.url().default("http://localhost:3000"),

Expand Down
37 changes: 37 additions & 0 deletions src/lib/encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import "server-only";
import crypto from "crypto";
import { env } from "@/env";

const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
const TAG_LENGTH = 16;

function getEncryptionKey(): Buffer {
return Buffer.from(env.FIELD_ENCRYPTION_KEY, "hex");
}

function getBlindIndexKey(): Buffer {
return Buffer.from(env.BLIND_INDEX_KEY, "hex");
}

export function encrypt(plaintext: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, getEncryptionKey(), iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, encrypted, tag]).toString("base64url");
}

export function decrypt(ciphertext: string): string {
const buf = Buffer.from(ciphertext, "base64url");
const iv = buf.subarray(0, IV_LENGTH);
const tag = buf.subarray(buf.length - TAG_LENGTH);
const encrypted = buf.subarray(IV_LENGTH, buf.length - TAG_LENGTH);
const decipher = crypto.createDecipheriv(ALGORITHM, getEncryptionKey(), iv);
decipher.setAuthTag(tag);
return decipher.update(encrypted) + decipher.final("utf8");
}

export function blindIndex(value: string): string {
return crypto.createHmac("sha256", getBlindIndexKey()).update(value.toLowerCase()).digest("hex");
}
27 changes: 19 additions & 8 deletions src/services/email-suppression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import "server-only";
import prisma from "@/lib/db";
import type { EmailSuppressionReason } from "@/generated/prisma/client";
import { transformError } from "@/utils/errors";
import { encrypt, blindIndex } from "@/lib/encryption";

export async function isEmailSuppressed(email: string): Promise<boolean> {
try {
const hash = blindIndex(email);
const suppression = await prisma.emailSuppression.findUnique({
where: { email: email.toLowerCase() },
where: { emailHash: hash },
});
return suppression !== null;
} catch (error) {
Expand All @@ -16,10 +18,14 @@ export async function isEmailSuppressed(email: string): Promise<boolean> {

export async function suppressEmail(email: string, reason: EmailSuppressionReason): Promise<void> {
try {
const normalized = email.toLowerCase();
const hash = blindIndex(normalized);
const encrypted = encrypt(normalized);

await prisma.emailSuppression.upsert({
where: { email: email.toLowerCase() },
where: { emailHash: hash },
update: { reason, suppressedAt: new Date() },
create: { email: email.toLowerCase(), reason },
create: { email: encrypted, emailHash: hash, reason },
});
} catch (error) {
throw transformError(error);
Expand All @@ -28,16 +34,21 @@ export async function suppressEmail(email: string, reason: EmailSuppressionReaso

export async function filterSuppressedEmails(emails: string[]): Promise<{ valid: string[]; suppressed: string[] }> {
try {
const hashToOriginal = new Map<string, string>();
for (const email of emails) {
hashToOriginal.set(blindIndex(email), email);
}

const suppressions = await prisma.emailSuppression.findMany({
where: { email: { in: emails.map((e) => e.toLowerCase()) } },
select: { email: true },
where: { emailHash: { in: Array.from(hashToOriginal.keys()) } },
select: { emailHash: true },
});

const suppressedSet = new Set(suppressions.map((s) => s.email));
const suppressedHashes = new Set(suppressions.map((s) => s.emailHash));

return {
valid: emails.filter((e) => !suppressedSet.has(e.toLowerCase())),
suppressed: emails.filter((e) => suppressedSet.has(e.toLowerCase())),
valid: emails.filter((e) => !suppressedHashes.has(blindIndex(e))),
suppressed: emails.filter((e) => suppressedHashes.has(blindIndex(e))),
};
} catch (error) {
throw transformError(error);
Expand Down
Loading
Loading