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>
57 lines
3.0 KiB
TypeScript
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`);
|
|
}
|
|
}
|