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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1eb79fee99
commit
253fbb4b2a
@@ -0,0 +1,56 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user