11 KiB
ترحيل intaleqDBV2 → v3 (CBC القديم → GCM + الفهارس العمياء)
دراسة مبنيّة على مقارنة فعلية بين intaleqDBV2.sql و backend/schema_primary.sql
والكود الحيّ في backend/auth/**, backend/serviceapp/**, backend/Admin/**.
1. نطاق الترحيل — الجداول التي فيها بيانات فعلاً
من 91 جدولاً في intaleqDBV2.sql، 12 فقط تحوي INSERT:
| الجدول | عدد عبارات INSERT | يُرحَّل تلقائياً؟ |
|---|---|---|
passengers |
42 | ✅ |
driver |
24 | ✅ |
tokens |
20 | ⏸ opt-in |
phone_verification |
13 | ⏸ opt-in |
driverToken |
12 | ⏸ opt-in |
CarRegistration |
9 | ✅ |
phone_verification_passenger |
7 | ⏸ opt-in |
token_verification_driver |
2 | ⏸ opt-in |
users |
1 | ✅ |
token_verification |
1 | ⏸ opt-in |
token_verification_admin |
1 | ⏸ opt-in |
employee |
1 | ✅ |
لماذا opt-in؟ tokens/driverToken جلسات JWT، وجداول *_verification رموز
OTP صلاحيتها 5 دقائق وكلها منتهية منذ شهور. ترحيلها بلا فائدة، وفيه خطر لأن صيغة
المفتاح تغيّرت (انظر §4). الأسلم: إهمالها وإجبار إعادة تسجيل دخول واحدة.
من أرادها: --table=tokens.
2. التشفير القديم — ما هو موجود فعلاً في v2
فحص العيّنات يثبت AES-256-CBC حتمي بـ IV ثابت: نفس النص يعطي نفس الـ ciphertext
عبر كل الصفوف (مثلاً 1CBSKV8j4EKo8w0zjY4zcf8P4nbtMK+zhKcLtuN13no= في driver.gender
لكل السائقين، و YJdUvEUnHWCdG/NAVZ3r/GyWXSYQr49nMGabOu0KAyc= في ستة أعمدة من
passengers). لو كان الـ IV عشوائياً لاختلفت كل قيمة.
لكن في الكود صيغتان كانتا تكتبان في نفس الأعمدة:
| المصدر | الصيغة |
|---|---|
core/Security/EncryptionHelper::encryptDataCBC |
base64(cipher) — IV ثابت، بلا بادئة |
encrypt_decrypt.php::encryptData (الجذر) |
base64(iv ‖ cipher) — IV عشوائي مُلحق |
⚠️ EncryptionHelper::decryptData الجديد لا يعرف الصيغة الثانية إطلاقاً — يجرّب
الـ IV الثابت فقط ويرجع false. أي ترحيل يعتمد عليه وحده سيُخرج صفوفاً فارغة بصمت.
لذلك السكربت يحمل فكّاكاً خاصاً (LegacyDecryptor) يجرّب الصيغتين.
⚠️ الصيغتان غير قابلتين للتمييز بشكل قاطع: في CBC يؤثّر الـ IV على البلوك الأول
فقط، فأي قيمة من بلوكين فأكثر تمرّ في المسارين بحشو صحيح. المصفاة الوحيدة هي
صلاحية UTF-8. لذلك السكربت لا يخمّن لكل قيمة، بل يعتمد صيغة مفضّلة
(--legacy-format=fixed افتراضاً، مبنيّة على الدليل أعلاه) ويعدّ الحالات الملتبسة
ويعرضها في التقرير.
نتيجة الفحص الفعلي على كامل intaleqDBV2.sql
شُغّل الفكّاك على كل قيمة مشفّرة في الجداول الخمسة المستهدفة بالمفتاح والـ IV الفعليين (32 و16 بايت):
driver 1447 صفاً كل الأعمدة cbc_fixed
passengers 2891 صفاً كل الأعمدة cbc_fixed
users 1 صف cbc_fixed
CarRegistration 1500 صفاً cbc_fixed
─────────────────────────────────────────────
cbc_fixed=50364 cbc_random=0 FAILED=0
الخلاصة: الصيغة fixed هي المستعملة حصراً — لا وجود لصيغة الـ IV العشوائي في
البيانات. الالتباس النظري المذكور أعلاه لم يقع عملياً (المسار العشوائي لم ينجح
في أي قيمة)، فالافتراضي --legacy-format=fixed صحيح ولا حاجة لتغييره.
شذوذات مكتشفة (كلها يعالجها السكربت):
| الحالة | العدد | المعالجة |
|---|---|---|
driver.phone مخزَّن نصّاً عادياً |
1 | يُشفَّر ويُفهرَس كغيره |
CarRegistration.car_plate نصّ عادي |
6 | تُشفَّر |
CarRegistration.vin = sdf / unknown |
237 | unknown يمرّ كما هو، sdf يُشفَّر |
driver.site = demascus نصّ عادي |
56 | يُشفَّر |
driver.fullNameMaritial = yet/NULL |
1447 | يمرّ كما هو |
القيمة المتكرّرة في ستة أعمدة من passengers فُكّت إلى unknown — أي أن الكود
القديم كان يكتب قيمة موحّدة، وهو ما يفعله $unknown_encrypted في
auth/passenger/register.php اليوم. سلوك مقصود لا خلل.
الأعمدة المشفّرة (مؤكَّدة بالعيّنات)
| الجدول | مشفّر | نصّ عادي |
|---|---|---|
driver |
phone, email, gender, national_number, name_arabic, address, birthdate, site, first_name, last_name, fullNameMaritial | password (bcrypt), id (hex), التواريخ, license_type, status |
passengers |
phone, email, gender, birthdate, site, first_name, last_name, sosPhone, education, employmentType, maritalStatus | password, id, status |
users |
fingerprint, phone, email, first_name, last_name | gender, birthdate, site, password, status, user_type |
CarRegistration |
vin, car_plate, owner | driverID, make, model, color, fuel, … |
employee |
— (كله نصّ عادي) | الكل |
| جداول OTP | phone_number, token/token_code, email | التواريخ, verified |
تنبيه: قيَم مثل yet / none / sos مخزَّنة نصّاً عادياً ويقارنها الكود حرفياً
(WHERE email = 'yet'). تشفيرها يكسر تلك المقارنات — السكربت يمرّرها كما هي
(SENTINELS).
3. التشفير الجديد — ما الذي يكتبه النظام اليوم
- التخزين:
AES-256-GCMببادئةGCM:و IV عشوائي 12 بايت + tag 16 بايت. مفعّل بـENCRYPTION_MODE=gcm؛ القراءة تدعم الصيغتين تلقائياً. - البحث:
BlindIndex=HMAC-SHA256(scope:normalized_value, BLIND_INDEX_PEPPER). الـ scope يشمل الجدول والحقل عمداً حتى لا يُربط نفس الرقم بينdriverوpassengers. التطبيع يوحّد07…و+9627…، ويوحّد أشكال الألف/الياء/التاء المربوطة للأسماء. - ربط جداول التحقق:
otpPhoneKey()='K:' . index('otp.phone', $phone). - بصمة الجهاز:
fingerprintمشفّر +fingerprint_hash = sha256(البصمة الخام).
السكربت يفرض GCM على الكتابة بغضّ النظر عن ENCRYPTION_MODE — هذا غرضه.
4. فجوات المخطط — يجب سدّها قبل الترحيل
هذه ليست ملاحظات تجميلية؛ اثنتان منها تكسران الإنتاج اليوم بمعزل عن الترحيل.
driverينقصهname_bidxوphone_key—auth/driver/register.php:444يُدخلهما، فالتسجيل يفشل. نفس الشيء لـpassengers(auth/passenger/register.php:133).token_verification_admin:phone_number VARCHAR(20),token VARCHAR(10)— بينماauth/otp/request.php:131يكتب مفتاحK:بطول 66 حرفاً و OTP بـ GCM بطول ~90. النتيجة قطع صامت ⇒ لا يمكن التحقق من أي OTP للأدمن.usersفيschema_primaryقديم جداً — ينقصهfingerprint,fingerprint_hash,status,country,phone_bidx,email_bidx(يستخدمهاserviceapp/register.php:76,serviceapp/login.php:24,Admin/Staff/add.php:71). كما أنphone VARCHAR(15)لا يتّسع لنصّ مشفّر.- فهارس UNIQUE على أعمدة مشفّرة تفقد معناها تحت GCM:
driver.national_number,passengers (phone, email),users.email/phone. التشفير العشوائي يجعل نفس الرقم ينتج نصاً مختلفاً كل مرة، فالـ UNIQUE يمنع تكرار النص المشفّر لا تكرار الرقم — أي يسمح بحسابين بنفس الهاتف. البديل: UNIQUE على*_bidx.
كلها في migrations/2026_07_29_v2_migration_schema_fixes.sql.
تعارض جانبي: 2026_07_25_blind_index.sql يضيف phone_bidx/email_bidx لـ
driver/passengers/adminUser، وهي موجودة أصلاً في schema_primary.sql.
على قاعدة جديدة من schema_primary تخطَّ تلك المايغريشن (عدا جزء
adminUser.status المعلَّق في آخر ملف الإصلاحات).
5. البيانات المفقودة عمداً
driver.api_key/api_secretوpassengers.*وusers.*— أُسقطت من المخطط الجديد لصالح جدولapi_keysالمستقل. لا تُنقل.users.statusموجود في v2 (approved) ويُعاد إدخاله بعد إضافة العمود.
6. خطوات التنفيذ
# 0) نسخة احتياطية من الهدف
mysqldump "$DB_PRIMARY_NAME_V2" > backup_before_migration.sql
# 1) سدّ فجوات المخطط
mysql "$DB_PRIMARY_NAME_V2" < backend/migrations/2026_07_29_v2_migration_schema_fixes.sql
# 2) تأكيد أن المفتاح والـ IV صحيحان — لا تتقدّم إن لم يكن FAILED=0
php backend/scripts/migrate_v2_reencrypt.php --probe
# 3) تشغيل جاف
php backend/scripts/migrate_v2_reencrypt.php --dry-run
# 4) التنفيذ الفعلي
php backend/scripts/migrate_v2_reencrypt.php --truncate
# 5) التدقيق — يفحص GCM والانحراف في الفهارس
php backend/scripts/migrate_v2_reencrypt.php --verify
# 6) تفعيل الكتابة بـ GCM في .env ثم إعادة تحميل PHP-FPM
# ENCRYPTION_MODE=gcm
السكربت قابل لإعادة التشغيل: يتخطّى الصفوف الموجودة بالمفتاح الأساسي ما لم
تُمرَّر --force، ويعمل على دفعات داخل معاملات مع فاصل قصير.
7. ما يبقى للمراجعة البشرية
BLIND_INDEX_PEPPERلا يُغيَّر بعد الترحيل أبداً. كل قيَم*_bidxوphone_keyمشتقّة منه؛ تغييره لاحقاً يُبطل كل عمليات البحث وتسجيل الدخول دفعةً واحدة، ويتطلّب إعادة بناء الفهارس كاملة (backfill_blind_index.php --force).driver.siteيحمل نفس ciphertext الخاص بـaddressفي بعض الصفوف — يستحق نظرة قبل الاعتماد علىsiteفي التوجيه الجغرافي. ليس من شأن الترحيل إصلاحه.- مفاتيح التشفير لا تُخزَّن في المستودع. السكربت يقرأها من
ENCRYPTION_KEY_PATH/ENC_KEYوinitializationVectorوقت التشغيل فقط.