Files
tripz-llc/backend/src/database/migrations/1721900000000-EncryptPhone.ts
T
Hamza-AyedandClaude Opus 4.8 253fbb4b2a feat: المجموعة E (كشف الاحتيال) + تشفير الهاتف at-rest
E — اتصال ثم إلغاء (من driver_ride_scam عند سيرو):
- E1: driver_called_passenger + last_call_at/by/count على الرحلة نفسها لا
  Redis فقط — حقيقة دائمة تفيد التحقيق بعد انتهاء الرحلة وزوالها من الكاش.
  يُسجَّل عند call:offer (بدء المكالمة الفعلي) في RealtimeGateway عبر
  Repository<Trip> مباشرة — لا استيراد TripsModule كاملاً (يستورد
  RealtimeModule أصلاً، فدورة استيراد)
- E2: FraudService.recordCallThenCancel — عدّاد يومي منفصل عن عدّاد الإلغاء
  العام بالساعة. 3/يوم = إنذار لا يمنع الإلغاء، 6/يوم = حظر مؤقت (طلب
  المالك حرفياً). يرتبط فقط إن كانت المكالمة خلال 30 دقيقة من الإلغاء

تشفير الهاتف at-rest (ملاحظة المالك) — بفهرس أعمى منفصل، لا تشفير ساذج:
- AES-256-GCM عشوائي IV يمنع WHERE phone = :x من المطابقة أصلاً (كل تشفير
  مختلف شكلاً حتى لنفس الرقم) — لو طُبِّق التشفير مباشرة بلا هذا لكسر تسجيل
  الدخول بالكامل بصمت
- users.phone_bidx = HMAC-SHA256(الرقم المطبَّع) — حتمي، يصلح للبحث
  وللقيد الفريد. القيد انتقل من (tenant_id, phone) إلى (tenant_id,
  phone_bidx) — القديم كان سيقبل تكرار نفس الرقم الحقيقي فعلياً
- findByPhone/create يحسبان البصمة؛ لا نقطة أخرى تقرأ phone بمساواة خام
- هجرة EncryptPhone تُشفّر الصفوف القائمة فوراً وتبني البصمة بأثر رجعي —
  استثناء متعمَّد من تسامح decrypt() المعتاد (الهاتف عمود بحث يومي، خطره
  كنص صافٍ أعلى من name)
- يعتمد على تطبيع D1: بصمة حتمية لنفس *النص*، فلا بد أن يصل الرقم مطبَّعاً
  قبلها وإلا عاد نفس الرقم بصيغتين ليصير رقمين مرة أخرى

هجرات: TripCallSignal · EncryptPhone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:25:18 +03:00

57 lines
3.0 KiB
TypeScript

import { MigrationInterface, QueryRunner } from 'typeorm';
import { encrypt, blindIndex } from '../../common/crypto/crypto.util';
/**
* تشفير الهاتف at-rest + فهرس أعمى للبحث (docs/16، docs/17 — D1 إضافة).
*
* **لماذا فهرس منفصل**: `phone` بعد التشفير غير قابل للبحث بالمساواة إطلاقاً
* — AES-256-GCM يستعمل IV عشوائياً لكل عملية، فنفس الرقم الحقيقي ينتج شيفرة
* مختلفة كل مرة. القيد الفريد ونقاط `WHERE phone = …` (تسجيل الدخول بأكمله)
* ينتقلان إلى `phone_bidx` — بصمة حتمية (HMAC) لا تكشف الرقم لكنها تكرّر
* نفسها لنفس الرقم دائماً.
*
* تُشفَّر الصفوف القائمة في نفس الهجرة (لا تُترك نصّاً صافياً) — الحماية
* التلقائية عبر `decrypt()` المتسامح كانت ستُبقيها صافية للأبد بلا هذا.
*/
export class EncryptPhone1721900000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_users ADD COLUMN IF NOT EXISTS phone_bidx varchar`);
const rows: { id: string; phone: string }[] = await q.query(
`SELECT id, phone FROM tripz_users`,
);
for (const row of rows) {
// تسامح إعادة التشغيل الجزئي: لا نُشفّر قيمة مشفّرة أصلاً مرة أخرى.
const alreadyEncrypted = row.phone?.startsWith('v1:');
const canonicalPhone = alreadyEncrypted ? null : row.phone;
const bidx = blindIndex(canonicalPhone ?? row.phone);
if (alreadyEncrypted) {
await q.query(`UPDATE tripz_users SET phone_bidx = $1 WHERE id = $2`, [bidx, row.id]);
} else {
await q.query(`UPDATE tripz_users SET phone = $1, phone_bidx = $2 WHERE id = $3`, [
encrypt(row.phone),
bidx,
row.id,
]);
}
}
await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx SET NOT NULL`);
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone"`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx"
ON tripz_users (tenant_id, phone_bidx)
`);
}
public async down(q: QueryRunner): Promise<void> {
// لا رجوع لفكّ التشفير جماعياً هنا عمداً — decrypt() يبقى متاحاً عند
// القراءة عبر الـORM؛ التراجع عن هذه الهجرة يعني قبول عدم قابلية البحث
// إن أُعيد phone فهرساً فريداً وحده، فلا نعيد بناء ذلك القيد تلقائياً.
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx"`);
await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx DROP NOT NULL`);
await q.query(`ALTER TABLE tripz_users DROP COLUMN IF EXISTS phone_bidx`);
}
}