feat(security): encrypt customer personal data at rest
- Prisma extension seals booking name/email/phone/address and waitlist contacts with AES-256-GCM on write and opens them on read (incl. nested reads) - keyed-hash columns for exact email/phone lookups (privacy webhooks, booking search, waitlist de-duplication); migration 0002 - tests prove only ciphertext is stored; privacy/terms/SECURITY.md updated to match Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1e19b773cd
commit
1d2d943d54
40
SECURITY.md
Normal file
40
SECURITY.md
Normal file
@ -0,0 +1,40 @@
|
||||
# Datewise security and incident response policy
|
||||
|
||||
Owner: Metatroncube Software Solutions · Contact: info@metatroncubesolutions.com · Last reviewed: 2026-10-14
|
||||
|
||||
## Data we hold
|
||||
Shop settings and scheduling rules; bookings (order ID and number, method, location, date, time, items);
|
||||
customers' name, email, phone and delivery address for reminders and driver run sheets; waitlist emails
|
||||
and phone numbers. Merchant Twilio / WhatsApp credentials when connected.
|
||||
|
||||
## Data protection
|
||||
- **In transit:** HTTPS only (TLS at Nginx or Caddy).
|
||||
- **At rest:** customer name, email, phone and address (bookings and waitlist) and messaging credentials are
|
||||
encrypted with AES-256-GCM in the application before they reach the database (`app/lib/pii.server.ts`).
|
||||
Email and phone lookups use HMAC-SHA256 keyed hashes. The key (`APP_ENCRYPTION_KEY`) lives only in the
|
||||
server's `.env` and the team password manager.
|
||||
- **Backups:** nightly `pg_dump`, encrypted with AES-256 (`scripts/backup.sh`), kept 14 days.
|
||||
- **Minimisation:** postal codes typed into the cart are used for zone lookup and not stored with identity.
|
||||
- **Retention:** customer redaction requests clear that customer's details from bookings and delete their
|
||||
waitlist entries; all shop data is deleted 48 hours after uninstall (`shop/redact`).
|
||||
- Test and production are separate: development uses local databases and Shopify development stores.
|
||||
|
||||
## Access control
|
||||
- Production access (VPS, database, Shopify Partner/Dev Dashboard) is limited to named staff who need it.
|
||||
- SSH uses key-based login; Shopify and Git accounts use strong unique passwords and two-factor authentication.
|
||||
- Secrets (`.env`: Shopify secret, database password, `APP_ENCRYPTION_KEY`) are never stored in Git or chat.
|
||||
Rotate them when someone with access leaves (the encryption key needs a re-encryption step; see below).
|
||||
- Access is reviewed every quarter.
|
||||
|
||||
## Incident response
|
||||
1. **Detect and contain** (immediately): revoke exposed credentials, rotate secrets, block the access path,
|
||||
take the affected service offline if needed.
|
||||
2. **Assess** (within 24 h): what data, which shops, how many records, time window. Preserve logs.
|
||||
3. **Notify** (within 72 h of becoming aware): affected merchants by email, and Shopify via the Partner
|
||||
support channel; regulators where the law requires it.
|
||||
4. **Recover**: fix the root cause, restore from encrypted backups if needed, verify.
|
||||
5. **Review** (within 2 weeks): written post-mortem, follow-up actions, update this policy.
|
||||
|
||||
## Key rotation
|
||||
Changing `APP_ENCRYPTION_KEY` makes existing ciphertext unreadable. To rotate: deploy with both keys,
|
||||
re-encrypt rows with a one-off script, then remove the old key. Never change the key on its own.
|
||||
@ -1,5 +1,6 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { piiExtension } from "./lib/pii.server";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
@ -8,11 +9,13 @@ declare global {
|
||||
|
||||
/**
|
||||
* Rust-free Prisma client over node-postgres (no native engine binary needed,
|
||||
* which also makes shared/VPS hosting simpler).
|
||||
* which also makes shared/VPS hosting simpler). Every client encrypts customer
|
||||
* personal data at rest (lib/pii.server.ts). The extension keeps the field types,
|
||||
* so the client is typed as a plain PrismaClient.
|
||||
*/
|
||||
export function createPrisma(url = process.env.DATABASE_URL) {
|
||||
export function createPrisma(url = process.env.DATABASE_URL): PrismaClient {
|
||||
if (!url) throw new Error("DATABASE_URL is not set");
|
||||
return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
|
||||
return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) }).$extends(piiExtension) as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
function instance(): PrismaClient {
|
||||
|
||||
91
app/lib/__tests__/pii.server.test.ts
Normal file
91
app/lib/__tests__/pii.server.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Customer personal data is encrypted at rest.
|
||||
* Crypto unit tests always run; the database tests need TEST_DATABASE_URL (migrations applied).
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { createPrisma } from "../../db.server";
|
||||
import { blindIndex, seal, unseal } from "../../services/crypto.server";
|
||||
|
||||
describe("crypto helpers", () => {
|
||||
it("seals and unseals, and reads legacy plain text unchanged", () => {
|
||||
const s = seal("Priya Raman")!;
|
||||
expect(s.startsWith("enc1:")).toBe(true);
|
||||
expect(s).not.toContain("Priya");
|
||||
expect(unseal(s)).toBe("Priya Raman");
|
||||
expect(unseal("plain old value")).toBe("plain old value");
|
||||
expect(seal(null)).toBeNull();
|
||||
expect(seal(s)).toBe(s); // never double-encrypts
|
||||
});
|
||||
it("blind indexes normalise and differ by kind", () => {
|
||||
expect(blindIndex(" Priya@Example.com ", "email")).toBe(blindIndex("priya@example.com", "email"));
|
||||
expect(blindIndex("+1 (519) 555-0101", "phone")).toBe(blindIndex("+15195550101", "phone"));
|
||||
expect(blindIndex("x", "email")).not.toBe(blindIndex("x", "phone"));
|
||||
expect(blindIndex("", "email")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const url = process.env.TEST_DATABASE_URL;
|
||||
const d = url ? describe : describe.skip;
|
||||
|
||||
d("PII encryption at rest (Postgres)", () => {
|
||||
let db: PrismaClient;
|
||||
let shopId: string;
|
||||
const base = { method: "delivery", status: "confirmed", timezone: "America/Toronto", configVersion: 1, snapshot: {} };
|
||||
const pii = {
|
||||
customerName: "Priya Raman",
|
||||
customerEmail: "Priya@Example.com",
|
||||
customerPhone: "+1 519 555 0101",
|
||||
address: { line1: "12 Guelph St", city: "Kitchener", postal: "N2H 5Z6" },
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
db = createPrisma(url);
|
||||
});
|
||||
afterAll(async () => db.$disconnect());
|
||||
beforeEach(async () => {
|
||||
await db.shop.deleteMany({ where: { domain: "pii.myshopify.com" } });
|
||||
shopId = (await db.shop.create({ data: { domain: "pii.myshopify.com", settings: {} } })).id;
|
||||
});
|
||||
|
||||
it("stores only ciphertext and reads back plain values", async () => {
|
||||
const b = await db.booking.create({ data: { shopId, orderId: "o1", ...base, ...pii } });
|
||||
expect(b.customerName).toBe("Priya Raman");
|
||||
expect(b.address).toEqual(pii.address);
|
||||
const [raw] = await db.$queryRaw<{ customerName: string; customerEmail: string; customerPhone: string; address: unknown }[]>`
|
||||
SELECT "customerName", "customerEmail", "customerPhone", "address" FROM "Booking" WHERE id = ${b.id}`;
|
||||
const dump = JSON.stringify(raw);
|
||||
for (const secret of ["Priya", "Example.com", "555", "Guelph", "N2H"]) expect(dump).not.toContain(secret);
|
||||
expect(raw.customerEmail.startsWith("enc1:")).toBe(true);
|
||||
const again = await db.booking.findUniqueOrThrow({ where: { id: b.id } });
|
||||
expect([again.customerName, again.customerEmail, again.customerPhone]).toEqual([pii.customerName, pii.customerEmail, pii.customerPhone]);
|
||||
});
|
||||
|
||||
it("decrypts nested reads (reminder job → booking)", async () => {
|
||||
const b = await db.booking.create({ data: { shopId, orderId: "o2", ...base, ...pii } });
|
||||
await db.reminderJob.create({ data: { shopId, bookingId: b.id, policyId: "p", channel: "email", sendAt: new Date(), body: "hi" } });
|
||||
const j = await db.reminderJob.findFirstOrThrow({ where: { bookingId: b.id }, include: { booking: true } });
|
||||
expect(j.booking.customerEmail).toBe(pii.customerEmail);
|
||||
});
|
||||
|
||||
it("finds by email/phone hash and redacts everything", async () => {
|
||||
await db.booking.create({ data: { shopId, orderId: "o3", ...base, ...pii } });
|
||||
const hash = blindIndex("priya@example.com", "email")!;
|
||||
expect(await db.booking.count({ where: { shopId, customerEmailHash: hash } })).toBe(1);
|
||||
expect(await db.booking.count({ where: { shopId, customerPhoneHash: blindIndex("+15195550101", "phone")! } })).toBe(1);
|
||||
await db.booking.updateMany({
|
||||
where: { shopId, customerEmailHash: hash },
|
||||
data: { customerName: null, customerEmail: null, customerPhone: null, customerEmailHash: null, customerPhoneHash: null },
|
||||
});
|
||||
const r = await db.booking.findFirstOrThrow({ where: { shopId, orderId: "o3" } });
|
||||
expect([r.customerName, r.customerEmail, r.customerPhone, r.customerEmailHash]).toEqual([null, null, null, null]);
|
||||
});
|
||||
|
||||
it("encrypts waitlist contacts and matches them by hash", async () => {
|
||||
await db.waitlistEntry.create({ data: { shopId, locationId: "kit", method: "delivery", date: "2026-11-07", slot: "*", email: "a@b.co", name: "Anu" } });
|
||||
const [raw] = await db.$queryRaw<{ email: string; name: string }[]>`SELECT email, name FROM "WaitlistEntry" WHERE "shopId" = ${shopId}`;
|
||||
expect(raw.email).not.toContain("a@b.co");
|
||||
const w = await db.waitlistEntry.findFirstOrThrow({ where: { shopId, emailHash: blindIndex("A@B.co", "email")! } });
|
||||
expect([w.email, w.name]).toEqual(["a@b.co", "Anu"]);
|
||||
});
|
||||
});
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { blindIndex } from "../services/crypto.server";
|
||||
|
||||
/** Booking list filters shared by the list page and the CSV export. */
|
||||
export function bookingWhere(shopId: string, sp: URLSearchParams, today: string): Prisma.BookingWhereInput {
|
||||
@ -15,6 +16,12 @@ export function bookingWhere(shopId: string, sp: URLSearchParams, today: string)
|
||||
const to = sp.get("to");
|
||||
if (from || to) where.date = { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) };
|
||||
const q = sp.get("q")?.trim();
|
||||
if (q) where.AND = [{ OR: [{ orderName: { contains: q, mode: "insensitive" } }, { customerName: { contains: q, mode: "insensitive" } }, { customerEmail: { contains: q, mode: "insensitive" } }] }];
|
||||
// Customer details are encrypted at rest: match order numbers by text, emails and phones exactly (by keyed hash).
|
||||
if (q) {
|
||||
const or: Prisma.BookingWhereInput[] = [{ orderName: { contains: q, mode: "insensitive" } }];
|
||||
if (q.includes("@")) or.push({ customerEmailHash: blindIndex(q, "email") });
|
||||
else if (/^[\d\s()+.-]{7,}$/.test(q)) or.push({ customerPhoneHash: blindIndex(q, "phone") });
|
||||
where.AND = [{ OR: or }];
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
101
app/lib/pii.server.ts
Normal file
101
app/lib/pii.server.ts
Normal file
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Encryption at rest for customer personal data, applied to every Prisma client (db.server.ts).
|
||||
*
|
||||
* Booking: customerName, customerEmail, customerPhone, address (+ customerEmailHash, customerPhoneHash)
|
||||
* WaitlistEntry: name, email, phone (+ emailHash, phoneHash)
|
||||
*
|
||||
* Writes are sealed with AES-256-GCM in a query extension; reads are opened in a result extension,
|
||||
* which also covers nested reads (e.g. reminderJob → booking). Code that queries by email or phone
|
||||
* must use the *Hash columns with blindIndex(), because ciphertext can't be matched.
|
||||
*/
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { blindIndex, seal, unseal } from "../services/crypto.server";
|
||||
|
||||
type Data = Record<string, unknown>;
|
||||
|
||||
function sealAddress(v: unknown) {
|
||||
if (v == null || v === Prisma.DbNull || v === Prisma.JsonNull) return v;
|
||||
if (typeof v === "object" && v && "enc1" in v) return v;
|
||||
return { enc1: seal(JSON.stringify(v)) };
|
||||
}
|
||||
|
||||
function openAddress(v: unknown) {
|
||||
if (v && typeof v === "object" && "enc1" in v && typeof (v as { enc1: unknown }).enc1 === "string") {
|
||||
return JSON.parse(unseal((v as { enc1: string }).enc1) ?? "null");
|
||||
}
|
||||
return v ?? null;
|
||||
}
|
||||
|
||||
const str = (v: unknown) => (typeof v === "string" ? v : null);
|
||||
|
||||
function sealBooking(d: Data | undefined) {
|
||||
if (!d) return d;
|
||||
const out: Data = { ...d };
|
||||
if ("customerName" in d) out.customerName = seal(str(d.customerName));
|
||||
if ("customerEmail" in d) {
|
||||
out.customerEmail = seal(str(d.customerEmail));
|
||||
out.customerEmailHash = blindIndex(str(d.customerEmail), "email");
|
||||
}
|
||||
if ("customerPhone" in d) {
|
||||
out.customerPhone = seal(str(d.customerPhone));
|
||||
out.customerPhoneHash = blindIndex(str(d.customerPhone), "phone");
|
||||
}
|
||||
if ("address" in d) out.address = sealAddress(d.address);
|
||||
return out;
|
||||
}
|
||||
|
||||
function sealWaitlist(d: Data | undefined) {
|
||||
if (!d) return d;
|
||||
const out: Data = { ...d };
|
||||
if ("name" in d) out.name = seal(str(d.name));
|
||||
if ("email" in d) {
|
||||
out.email = seal(str(d.email));
|
||||
out.emailHash = blindIndex(str(d.email), "email");
|
||||
}
|
||||
if ("phone" in d) {
|
||||
out.phone = seal(str(d.phone));
|
||||
out.phoneHash = blindIndex(str(d.phone), "phone");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Applies a sealer to the data of create / update / upsert / createMany / updateMany. */
|
||||
function sealArgs(args: Data, sealer: (d: Data | undefined) => Data | undefined) {
|
||||
const a: Data = { ...args };
|
||||
if (Array.isArray(a.data)) a.data = (a.data as Data[]).map((x) => sealer(x));
|
||||
else if (a.data) a.data = sealer(a.data as Data);
|
||||
if (a.create) a.create = sealer(a.create as Data);
|
||||
if (a.update) a.update = sealer(a.update as Data);
|
||||
return a;
|
||||
}
|
||||
|
||||
const WRITES = new Set(["create", "createMany", "createManyAndReturn", "update", "updateMany", "updateManyAndReturn", "upsert"]);
|
||||
|
||||
export const piiExtension = Prisma.defineExtension({
|
||||
name: "datewise-pii",
|
||||
query: {
|
||||
booking: {
|
||||
$allOperations({ operation, args, query }) {
|
||||
return query(WRITES.has(operation) ? (sealArgs(args as Data, sealBooking) as typeof args) : args);
|
||||
},
|
||||
},
|
||||
waitlistEntry: {
|
||||
$allOperations({ operation, args, query }) {
|
||||
return query(WRITES.has(operation) ? (sealArgs(args as Data, sealWaitlist) as typeof args) : args);
|
||||
},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
booking: {
|
||||
customerName: { needs: { customerName: true }, compute: (b) => unseal(b.customerName) },
|
||||
customerEmail: { needs: { customerEmail: true }, compute: (b) => unseal(b.customerEmail) },
|
||||
customerPhone: { needs: { customerPhone: true }, compute: (b) => unseal(b.customerPhone) },
|
||||
address: { needs: { address: true }, compute: (b) => openAddress(b.address) as Prisma.JsonValue },
|
||||
},
|
||||
waitlistEntry: {
|
||||
name: { needs: { name: true }, compute: (w) => unseal(w.name) },
|
||||
email: { needs: { email: true }, compute: (w) => unseal(w.email) },
|
||||
phone: { needs: { phone: true }, compute: (w) => unseal(w.phone) },
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -64,7 +64,7 @@ export default function Bookings() {
|
||||
</s-select>
|
||||
<s-date-field label="From" name="from" value={sp.get("from") ?? ""} />
|
||||
<s-date-field label="To" name="to" value={sp.get("to") ?? ""} />
|
||||
<s-search-field label="Search" name="q" placeholder="Order, name or email" value={sp.get("q") ?? ""} />
|
||||
<s-search-field label="Search" name="q" placeholder="Order number, or a full email or phone" value={sp.get("q") ?? ""} />
|
||||
<s-button type="submit">Apply</s-button>
|
||||
</s-grid>
|
||||
</Form>
|
||||
|
||||
@ -45,8 +45,10 @@ export default function Privacy() {
|
||||
|
||||
<h2>Security</h2>
|
||||
<p>
|
||||
Data is encrypted in transit (HTTPS). Messaging credentials you enter are encrypted at rest. Access to our systems is limited
|
||||
to authorised staff. Our data processing terms are in the <a href="/terms">terms of service</a>.
|
||||
Data is encrypted in transit (HTTPS). Customers' names, emails, phone numbers and addresses, and the messaging credentials
|
||||
you enter, are encrypted at rest (AES-256-GCM); lookups use keyed hashes, never the plain values. Database backups are
|
||||
encrypted. Access to our systems is limited to authorised staff. Our data processing terms are in
|
||||
the <a href="/terms">terms of service</a>.
|
||||
</p>
|
||||
|
||||
<h2>Retention and deletion</h2>
|
||||
|
||||
@ -35,7 +35,7 @@ export default function Terms() {
|
||||
<ul>
|
||||
<li>process customer data (name, email, phone, delivery address, order and booking details) only to provide the app's features and on your instructions given through the app;</li>
|
||||
<li>do not sell or share personal data, and do not use it for advertising, profiling, automated decisions or training AI models;</li>
|
||||
<li>encrypt data in transit (HTTPS) and encrypt messaging credentials at rest;</li>
|
||||
<li>encrypt data in transit (HTTPS), encrypt customer contact details and messaging credentials at rest (AES-256-GCM, keyed hashing), and encrypt backups;</li>
|
||||
<li>limit access to our systems to authorised staff bound by confidentiality;</li>
|
||||
<li>use these sub-processors: our hosting provider, Resend (email) and Google Maps (delivery distances); Twilio and Meta only when you connect your own accounts. We will tell you before adding others;</li>
|
||||
<li>notify you without undue delay, and within 72 hours, after becoming aware of a breach affecting your data;</li>
|
||||
|
||||
@ -2,6 +2,7 @@ import { Prisma } from "@prisma/client";
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import prisma from "../db.server";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import { blindIndex } from "../services/crypto.server";
|
||||
|
||||
/**
|
||||
* Mandatory privacy webhooks.
|
||||
@ -13,24 +14,27 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { shop, topic, payload } = await authenticate.webhook(request);
|
||||
const p = payload as { customer?: { email?: string; phone?: string }; orders_to_redact?: number[] };
|
||||
const s = await prisma.shop.findUnique({ where: { domain: shop } });
|
||||
// Contact details are encrypted at rest; match them by keyed hash.
|
||||
const emailHash = blindIndex(p.customer?.email, "email");
|
||||
const phoneHash = blindIndex(p.customer?.phone, "phone");
|
||||
if (!s) return new Response();
|
||||
|
||||
if (topic === "CUSTOMERS_DATA_REQUEST") {
|
||||
const n = await prisma.booking.count({ where: { shopId: s.id, customerEmail: p.customer?.email ?? "__none__" } });
|
||||
const n = emailHash ? await prisma.booking.count({ where: { shopId: s.id, customerEmailHash: emailHash } }) : 0;
|
||||
console.info(`data_request for ${shop}: ${n} bookings hold this customer's contact details`);
|
||||
}
|
||||
if (topic === "CUSTOMERS_REDACT") {
|
||||
const orderIds = (p.orders_to_redact ?? []).map((id) => `gid://shopify/Order/${id}`);
|
||||
const where = {
|
||||
shopId: s.id,
|
||||
OR: [{ orderId: { in: orderIds } }, ...(p.customer?.email ? [{ customerEmail: p.customer.email }] : [])],
|
||||
OR: [{ orderId: { in: orderIds } }, ...(emailHash ? [{ customerEmailHash: emailHash }] : [])],
|
||||
};
|
||||
await prisma.booking.updateMany({
|
||||
where,
|
||||
data: { customerName: null, customerEmail: null, customerPhone: null, address: Prisma.DbNull },
|
||||
data: { customerName: null, customerEmail: null, customerPhone: null, address: Prisma.DbNull, customerEmailHash: null, customerPhoneHash: null },
|
||||
});
|
||||
if (p.customer?.email || p.customer?.phone)
|
||||
await prisma.waitlistEntry.deleteMany({ where: { shopId: s.id, OR: [{ email: p.customer?.email ?? "__" }, { phone: p.customer?.phone ?? "__" }] } });
|
||||
if (emailHash || phoneHash)
|
||||
await prisma.waitlistEntry.deleteMany({ where: { shopId: s.id, OR: [...(emailHash ? [{ emailHash }] : []), ...(phoneHash ? [{ phoneHash }] : [])] } });
|
||||
}
|
||||
if (topic === "SHOP_REDACT") {
|
||||
await prisma.shop.delete({ where: { id: s.id } });
|
||||
|
||||
@ -1,22 +1,53 @@
|
||||
/** AES-256-GCM for merchant messaging credentials. APP_ENCRYPTION_KEY = 32 random bytes, base64. */
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
/**
|
||||
* AES-256-GCM encryption at rest (messaging credentials and customer contact details) and keyed
|
||||
* hashes ("blind indexes") for exact-match lookups on encrypted values.
|
||||
* APP_ENCRYPTION_KEY = 32 random bytes, base64 (`openssl rand -base64 32`).
|
||||
*/
|
||||
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "node:crypto";
|
||||
|
||||
function key(): Buffer {
|
||||
const k = Buffer.from(process.env.APP_ENCRYPTION_KEY ?? "", "base64");
|
||||
if (k.length !== 32) throw new Error("APP_ENCRYPTION_KEY must be 32 bytes (base64)");
|
||||
return k;
|
||||
let cached: { raw: string; enc: Buffer; idx: Buffer } | null = null;
|
||||
function keys() {
|
||||
const raw = process.env.APP_ENCRYPTION_KEY ?? "";
|
||||
if (cached?.raw === raw) return cached;
|
||||
const enc = Buffer.from(raw, "base64");
|
||||
if (enc.length !== 32) throw new Error("APP_ENCRYPTION_KEY must be 32 bytes (base64)");
|
||||
// A separate key for hashing, derived so one secret covers both uses.
|
||||
const idx = createHmac("sha256", enc).update("datewise:blind-index:v1").digest();
|
||||
cached = { raw, enc, idx };
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function encrypt(plain: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const c = createCipheriv("aes-256-gcm", key(), iv);
|
||||
const c = createCipheriv("aes-256-gcm", keys().enc, iv);
|
||||
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
|
||||
return [iv, c.getAuthTag(), enc].map((b) => b.toString("base64url")).join(".");
|
||||
}
|
||||
|
||||
export function decrypt(blob: string): string {
|
||||
const [iv, tag, enc] = blob.split(".").map((p) => Buffer.from(p, "base64url"));
|
||||
const d = createDecipheriv("aes-256-gcm", key(), iv);
|
||||
const d = createDecipheriv("aes-256-gcm", keys().enc, iv);
|
||||
d.setAuthTag(tag);
|
||||
return Buffer.concat([d.update(enc), d.final()]).toString("utf8");
|
||||
}
|
||||
|
||||
/** Marks sealed column values so rows written before encryption (plain text) still read correctly. */
|
||||
const PREFIX = "enc1:";
|
||||
|
||||
export function seal(plain: string | null | undefined): string | null | undefined {
|
||||
if (plain == null || plain === "" || plain.startsWith(PREFIX)) return plain;
|
||||
return PREFIX + encrypt(plain);
|
||||
}
|
||||
|
||||
export function unseal(value: string | null | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
return value.startsWith(PREFIX) ? decrypt(value.slice(PREFIX.length)) : value;
|
||||
}
|
||||
|
||||
/** Keyed hash of a normalised email or phone, for exact-match queries. Never reversible. */
|
||||
export function blindIndex(value: string | null | undefined, kind: "email" | "phone"): string | null {
|
||||
if (!value) return null;
|
||||
const norm = kind === "email" ? value.trim().toLowerCase() : value.replace(/[^\d+]/g, "");
|
||||
if (!norm) return null;
|
||||
return createHmac("sha256", keys().idx).update(`${kind}:${norm}`).digest("hex");
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ import { resolveAddress } from "./geocode.server";
|
||||
import { features } from "./plans";
|
||||
import { ReservationService } from "./reservations.server";
|
||||
import { SELECTION_ATTR } from "./settings";
|
||||
import { blindIndex } from "./crypto.server";
|
||||
|
||||
export const reservations = new ReservationService(prisma);
|
||||
|
||||
@ -260,7 +261,10 @@ export async function joinWaitlist(domain: string, raw: unknown) {
|
||||
const cfg = await loadShopConfig(domain);
|
||||
if (!features(cfg.shop.plan).waitlist) return { ok: false, message: "Waitlist isn't available" };
|
||||
const dup = await prisma.waitlistEntry.findFirst({
|
||||
where: { shopId: cfg.shop.id, locationId: w.locationId, method: w.method, date: w.date, OR: [{ email: w.email ?? "__" }, { phone: w.phone ?? "__" }] },
|
||||
where: {
|
||||
shopId: cfg.shop.id, locationId: w.locationId, method: w.method, date: w.date,
|
||||
OR: [{ emailHash: blindIndex(w.email, "email") ?? "__" }, { phoneHash: blindIndex(w.phone, "phone") ?? "__" }],
|
||||
},
|
||||
});
|
||||
if (!dup) await prisma.waitlistEntry.create({ data: { shopId: cfg.shop.id, ...w } });
|
||||
return { ok: true };
|
||||
|
||||
8
prisma/migrations/0002_pii_encryption/migration.sql
Normal file
8
prisma/migrations/0002_pii_encryption/migration.sql
Normal file
@ -0,0 +1,8 @@
|
||||
-- Customer contact details are now encrypted at rest by the app (app/lib/pii.server.ts).
|
||||
-- Keyed hashes allow exact-match lookups (privacy webhooks, booking search) without decrypting.
|
||||
ALTER TABLE "Booking" ADD COLUMN "customerEmailHash" TEXT;
|
||||
ALTER TABLE "Booking" ADD COLUMN "customerPhoneHash" TEXT;
|
||||
CREATE INDEX "Booking_shopId_customerEmailHash_idx" ON "Booking"("shopId", "customerEmailHash");
|
||||
|
||||
ALTER TABLE "WaitlistEntry" ADD COLUMN "emailHash" TEXT;
|
||||
ALTER TABLE "WaitlistEntry" ADD COLUMN "phoneHash" TEXT;
|
||||
@ -162,10 +162,13 @@ model Booking {
|
||||
timezone String
|
||||
startUtc DateTime?
|
||||
endUtc DateTime?
|
||||
/// Encrypted at rest (app/lib/pii.server.ts). Query email/phone through the *Hash columns.
|
||||
customerName String?
|
||||
customerEmail String?
|
||||
customerPhone String?
|
||||
address Json?
|
||||
customerEmailHash String?
|
||||
customerPhoneHash String?
|
||||
items Json?
|
||||
fee Int @default(0)
|
||||
peakDayId String?
|
||||
@ -181,6 +184,7 @@ model Booking {
|
||||
@@unique([shopId, orderId])
|
||||
@@index([shopId, date])
|
||||
@@index([shopId, status])
|
||||
@@index([shopId, customerEmailHash])
|
||||
}
|
||||
|
||||
model BookingEvent {
|
||||
@ -206,9 +210,12 @@ model WaitlistEntry {
|
||||
method String
|
||||
date String
|
||||
slot String // "*" = any slot that day
|
||||
/// Encrypted at rest (app/lib/pii.server.ts). Query through emailHash / phoneHash.
|
||||
email String?
|
||||
phone String?
|
||||
name String?
|
||||
emailHash String?
|
||||
phoneHash String?
|
||||
createdAt DateTime @default(now())
|
||||
offeredAt DateTime?
|
||||
/// Hold token reserved for this person when a place is offered.
|
||||
|
||||
10
vitest.config.ts
Normal file
10
vitest.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
// Test-only key (the bytes spell "datewise-test-key-not-a-secret!!"). Production reads APP_ENCRYPTION_KEY from .env.
|
||||
env: { APP_ENCRYPTION_KEY: "ZGF0ZXdpc2UtdGVzdC1rZXktbm90LWEtc2VjcmV0ISE=" },
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user