Files
intaleq/backend/migrations/MIGRATION_V2_TO_V3.md
T

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. فجوات المخطط — يجب سدّها قبل الترحيل

هذه ليست ملاحظات تجميلية؛ اثنتان منها تكسران الإنتاج اليوم بمعزل عن الترحيل.

  1. driver ينقصه name_bidx و phone_key — auth/driver/register.php:444 يُدخلهما، فالتسجيل يفشل. نفس الشيء لـ passengers (auth/passenger/register.php:133).
  2. token_verification_admin: phone_number VARCHAR(20), token VARCHAR(10) — بينما auth/otp/request.php:131 يكتب مفتاح K: بطول 66 حرفاً و OTP بـ GCM بطول ~90. النتيجة قطع صامت ⇒ لا يمكن التحقق من أي OTP للأدمن.
  3. 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) لا يتّسع لنصّ مشفّر.
  4. فهارس 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 وقت التشغيل فقط.