decryptData($value); if ($p !== false) { $this->statGcm++; return [$p, 'gcm']; } } $decoded = base64_decode($value, true); if ($decoded !== false && strlen($decoded) >= 16 && strlen($decoded) % 16 === 0) { $fixed = $this->tryCbc($decoded, $this->iv); $random = strlen($decoded) >= 32 ? $this->tryCbc(substr($decoded, 16), substr($decoded, 0, 16)) : null; if ($fixed !== null && $random !== null) $this->statAmbiguous++; $first = $this->prefer === 'random' ? $random : $fixed; $second = $this->prefer === 'random' ? $fixed : $random; if ($first !== null) { $this->prefer === 'random' ? $this->statRandomIv++ : $this->statFixedIv++; return [$first, $this->prefer === 'random' ? 'cbc_random' : 'cbc_fixed']; } if ($second !== null) { $this->prefer === 'random' ? $this->statFixedIv++ : $this->statRandomIv++; return [$second, $this->prefer === 'random' ? 'cbc_fixed' : 'cbc_random']; } } // لم يفكّ: إمّا نص عادي (yet/none/hex id) وهذا الغالب، أو تلف فعلي. // نميّز بينهما بشكل القيمة: ما يبدو base64 بطول بلوك ولم يفكّ = مشكلة. if ($decoded !== false && strlen($decoded) >= 32 && strlen($decoded) % 16 === 0 && preg_match('#^[A-Za-z0-9+/]+={0,2}$#', $value)) { $this->statFailed++; return [$value, 'fail']; } $this->statPlain++; return [$value, 'plain']; } private function tryCbc(string $payload, string $iv): ?string { $out = openssl_decrypt($payload, 'AES-256-CBC', $this->key, OPENSSL_RAW_DATA, $iv); if ($out === false || $out === '') return null; $pad = ord($out[strlen($out) - 1]); if ($pad < 1 || $pad > 16 || $pad > strlen($out)) return null; // تحقق من صحة كل بايتات الحشو — بدونه يمرّ فكّ خاطئ بنسبة 1/16 if (substr($out, -$pad) !== str_repeat(chr($pad), $pad)) return null; $plain = substr($out, 0, -$pad); // المفتاح الخاطئ يُنتج بايتات عشوائية؛ صلاحية UTF-8 هي المصفاة الأخيرة if ($plain !== '' && !mb_check_encoding($plain, 'UTF-8')) return null; return $plain; } } // ============================================================ // 2) خريطة الترحيل // ============================================================ /** * لكل جدول: * src/dst : اسم الجدول في المصدر/الهدف * pk : المفتاح المستخدم لكشف التكرار عند إعادة التشغيل * copy : أعمدة تُنقل كما هي * encrypt : أعمدة تُفكّ ثم يُعاد تشفيرها بـ GCM * otp_key : أعمدة تُفكّ ثم تُحوَّل إلى مفتاح otpPhoneKey() الحتمي * derive : أعمدة جديدة تُحسب من نصّ صريح * drop : أعمدة مصدر مقصود إسقاطها (توثيق فقط) * * `sentinels` قيَم نصّية ثابتة يقارنها الكود حرفياً (WHERE email = 'yet'). * تشفيرها يكسر تلك المقارنات، فتمرّ كما هي. */ const SENTINELS = ['yet', 'none', 'sos', 'unknown', 'null', 'NULL', '0', 'active', 'notDeleted']; $MAP = [ // ── driver ──────────────────────────────────────────────── 'driver' => [ 'pk' => 'id', 'copy' => ['idn','id','password','license_type','issue_date','expiry_date', 'license_categories','licenseIssueDate','status','accountBank','bankCode', 'employmentType','maritalStatus','expirationDate','created_at','updated_at'], 'encrypt' => ['phone','email','gender','national_number','name_arabic','address', 'birthdate','site','first_name','last_name','fullNameMaritial'], 'derive' => [ 'phone_bidx' => ['bidx', 'driver.phone', ['phone']], 'email_bidx' => ['bidx', 'driver.email', ['email']], 'name_bidx' => ['bidx', 'driver.name', ['first_name','last_name']], 'national_bidx' => ['bidx', 'driver.national', ['national_number']], 'phone_key' => ['otpkey', 'otp.phone', ['phone']], ], // api_key/api_secret أُسقطا من المخطط الجديد — مفاتيح HMAC انتقلت إلى // جدول api_keys المستقل. البيانات القديمة لا تُنقل عمداً. 'drop' => ['api_key','api_secret'], ], // ── passengers ──────────────────────────────────────────── 'passengers' => [ 'pk' => 'id', 'copy' => ['id','password','status','created_at','updated_at'], 'encrypt' => ['phone','email','gender','birthdate','site','first_name','last_name', 'sosPhone','education','employmentType','maritalStatus'], 'derive' => [ 'phone_bidx' => ['bidx', 'passengers.phone', ['phone']], 'email_bidx' => ['bidx', 'passengers.email', ['email']], 'name_bidx' => ['bidx', 'passengers.name', ['first_name','last_name']], 'phone_key' => ['otpkey', 'otp.phone', ['phone']], ], 'drop' => ['api_key','api_secret'], ], // ── users (حسابات خدمة العملاء) ──────────────────────────── 'users' => [ 'pk' => 'id', 'copy' => ['id','gender','password','birthdate','site','created_at','updated_at', 'user_type','status'], 'encrypt' => ['fingerprint','phone','email','first_name','last_name'], 'derive' => [ // serviceapp/login.php:24 يبحث بـ sha256 للبصمة الخام — يجب أن // يُعاد حسابه من النص الصريح لا نقله، لأن نصّ البصمة نفسه لم يتغيّر // لكن سلسلة sha256 القديمة قد تكون محسوبة على صيغة مختلفة. 'fingerprint_hash' => ['sha256', null, ['fingerprint']], 'phone_bidx' => ['bidx', 'users.phone', ['phone']], 'email_bidx' => ['bidx', 'users.email', ['email']], ], 'drop' => ['api_key','api_secret'], ], // ── CarRegistration ─────────────────────────────────────── 'CarRegistration' => [ 'pk' => 'id', 'copy' => ['id','driverID','make','model','year','expiration_date','color', 'color_hex','fuel','isDefault','created_at','status', 'vehicle_category_id','fuel_type_id'], 'encrypt' => ['vin','car_plate','owner'], 'derive' => [], ], // ── employee (نص عادي بالكامل — يُنقل كما هو) ─────────────── 'employee' => [ 'pk' => 'id', 'copy' => ['id','name','education','site','phone','created_at','status'], 'encrypt' => [], 'derive' => [], ], // ── tokens / driverToken (جلسات) ────────────────────────── // ملاحظة: هذه جلسات JWT وبصمات أجهزة. الأسلم إهمالها وإجبار إعادة تسجيل // الدخول مرة واحدة. تُرحَّل فقط عند تمرير --table صراحةً. 'tokens' => [ 'pk' => 'passengerID', 'opt_in' => true, 'copy' => ['id','passengerID','status'], 'encrypt' => ['token','fingerPrint'], 'derive' => [], ], 'driverToken' => [ 'pk' => 'id', 'opt_in' => true, 'copy' => ['id','captain_id','created_at'], 'encrypt' => ['token','fingerPrint'], 'derive' => [], ], // ── جداول OTP ───────────────────────────────────────────── // صلاحية كل رمز 5 دقائق، وكل صفوف v2 منتهية منذ شهور. ترحيلها بلا فائدة // وبخطر: صيغة المفتاح تغيّرت إلى otpPhoneKey() الحتمي. تُرحَّل بـ --table فقط. 'phone_verification' => [ 'pk' => 'id', 'opt_in' => true, 'copy' => ['id','expiration_time','is_verified','created_at'], 'encrypt' => ['email'], 'otp_key' => ['phone_number' => 'otp.phone', 'token_code' => 'otp.phone'], 'copy_as_is' => ['driverId'], 'derive' => [], ], 'phone_verification_passenger' => [ 'pk' => 'id', 'opt_in' => true, 'copy' => ['id','expiration_time','verified','created_at','status'], 'otp_key' => ['phone_number' => 'otp.phone', 'token' => 'otp.phone'], 'encrypt' => [], 'derive' => [], ], 'token_verification' => [ 'pk' => 'id', 'opt_in' => true, 'copy' => ['id','expiration_time','verified','created_at'], 'otp_key' => ['phone_number' => 'otp.phone', 'token' => 'otp.phone'], 'encrypt' => [], 'derive' => [], ], 'token_verification_driver' => [ 'pk' => 'id', 'opt_in' => true, 'copy' => ['id','expiration_time','verified','created_at'], 'otp_key' => ['phone_number' => 'otp.phone', 'token' => 'otp.phone'], 'encrypt' => [], 'derive' => [], ], 'token_verification_admin' => [ 'pk' => 'id', 'opt_in' => true, 'copy' => ['id','expiration_time'], // بيانات v2 هنا نصّ عادي (962798583052 / 95692) — تُحوَّل إلى صيغة // المفتاح الجديدة كما يكتبها auth/otp/request.php اليوم. 'otp_key' => ['phone_number' => 'otp.phone'], 'encrypt' => ['token'], 'derive' => [], ], ]; // ============================================================ // 3) التهيئة // ============================================================ /** @var EncryptionHelper $encryptionHelper */ global $encryptionHelper; $encKey = getenv('ENCRYPTION_KEY_PATH') && file_exists((string) getenv('ENCRYPTION_KEY_PATH')) ? trim((string) file_get_contents((string) getenv('ENCRYPTION_KEY_PATH'))) : (string) (getenv('ENC_KEY') ?: ''); $legacyIv = (string) (getenv('initializationVector') ?: ''); if (strlen($encKey) !== 32) exit("✘ ENC key must be exactly 32 bytes (got " . strlen($encKey) . ").\n"); if (strlen($legacyIv) !== 16) exit("✘ initializationVector must be exactly 16 bytes (got " . strlen($legacyIv) . ").\n"); $prefer = ($opt['legacy-format'] ?? 'fixed') === 'random' ? 'random' : 'fixed'; $legacy = new LegacyDecryptor($encKey, $legacyIv, $prefer); echo " legacy CBC format: $prefer IV" . ($prefer === 'fixed' ? '' : ' (16-byte prefix)') . "\n"; // الكتابة بـ GCM حصراً — هذا هو الغرض، ولا نتركه لإعداد بيئة قد يكون 'cbc' $writer = new EncryptionHelper($encKey, $legacyIv, 'gcm'); try { $blind = new BlindIndex(); } catch (RuntimeException $e) { exit("✘ " . $e->getMessage() . "\n"); } // المصدر $srcName = (string) (getenv('SRC_DB_NAME') ?: ''); if ($srcName === '') exit("✘ SRC_DB_NAME is not set (source = the old intaleqDBV2).\n"); $src = new PDO( sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', getenv('SRC_DB_HOST') ?: 'localhost', $srcName), (string) getenv('SRC_DB_USER'), (string) getenv('SRC_DB_PASS'), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] ); $dst = Database::get('main'); // ============================================================ // 4) أدوات مساعدة // ============================================================ function otpKeyOf(BlindIndex $b, string $scope, string $plain): ?string { $i = $b->index($scope, $plain); return $i === null ? null : 'K:' . $i; // نفس صيغة core/helpers.php::otpPhoneKey } function isSentinel(?string $v): bool { return $v === null || $v === '' || in_array($v, SENTINELS, true); } function targetColumns(PDO $pdo, string $table): array { $cols = []; foreach ($pdo->query("SHOW COLUMNS FROM `$table`") as $r) $cols[$r['Field']] = true; return $cols; } // ============================================================ // 5) --probe : فحص المفتاح قبل أي شيء // ============================================================ if (isset($opt['probe'])) { echo "── Probing legacy key/IV against live v2 data ──\n\n"; foreach (['driver' => ['phone','email','first_name'], 'passengers' => ['phone','email','first_name'], 'CarRegistration' => ['vin','car_plate']] as $t => $fields) { try { $rows = $src->query("SELECT " . implode(',', array_map(fn($f) => "`$f`", $fields)) . " FROM `$t` LIMIT 20")->fetchAll(); } catch (PDOException $e) { echo " ! $t: {$e->getMessage()}\n"; continue; } foreach ($fields as $f) { $ok = $fail = 0; $sample = ''; foreach ($rows as $r) { [$p, $fmt] = $legacy->decrypt($r[$f] ?? null); if ($fmt === 'fail') { $fail++; continue; } if (in_array($fmt, ['cbc_fixed','cbc_random','gcm'], true)) { $ok++; if ($sample === '') $sample = mb_substr($p, 0, 3) . str_repeat('*', max(0, mb_strlen($p) - 3)); } } printf(" %-18s %-14s ok=%-3d fail=%-3d %s\n", $t, $f, $ok, $fail, $sample); } } printf("\n formats seen: cbc_fixed=%d cbc_random=%d gcm=%d plain=%d FAILED=%d ambiguous=%d\n", $legacy->statFixedIv, $legacy->statRandomIv, $legacy->statGcm, $legacy->statPlain, $legacy->statFailed, $legacy->statAmbiguous); if ($legacy->statAmbiguous > 0) { echo " ! {$legacy->statAmbiguous} value(s) decrypted under BOTH IV schemes.\n" . " Compare a sample against the live app before trusting the chosen format,\n" . " or re-run with --legacy-format=random to see which output reads correctly.\n"; } echo $legacy->statFailed === 0 ? "\n✔ key and IV look correct — proceed with --dry-run.\n" : "\n✘ some values did not decrypt. Do NOT migrate until this is 0.\n"; exit($legacy->statFailed === 0 ? 0 : 1); } // ============================================================ // 6) --verify : تدقيق ما بعد الترحيل // ============================================================ if (isset($opt['verify'])) { echo "── Verifying migrated data ──\n\n"; $bad = 0; foreach (['driver' => ['phone','email','first_name','last_name'], 'passengers' => ['phone','email','first_name'], 'users' => ['phone','email'], 'CarRegistration' => ['vin','car_plate','owner']] as $t => $fields) { $sel = implode(',', array_map(fn($f) => "`$f`", $fields)); try { $rows = $dst->query("SELECT $sel FROM `$t`")->fetchAll(); } catch (PDOException $e) { echo " ! $t: {$e->getMessage()}\n"; continue; } $gcm = $legacyLeft = $broken = 0; foreach ($rows as $r) foreach ($fields as $f) { $v = $r[$f] ?? ''; if (isSentinel($v)) continue; if (str_starts_with((string) $v, 'GCM:')) { $gcm++; if ($writer->decryptData($v) === false) $broken++; } else { $legacyLeft++; } } $bad += $broken; printf(" %-18s gcm=%-6d still-legacy=%-6d undecryptable=%d\n", $t, $gcm, $legacyLeft, $broken); } // انحراف الفهارس: الفهرس المخزَّن مقابل المحسوب من القيمة المشفّرة echo "\n blind-index drift:\n"; foreach ([['driver','driver.phone','phone','phone_bidx'], ['driver','driver.email','email','email_bidx'], ['passengers','passengers.phone','phone','phone_bidx'], ['passengers','passengers.email','email','email_bidx']] as [$t,$scope,$srcCol,$idxCol]) { try { $rows = $dst->query("SELECT `$srcCol`,`$idxCol` FROM `$t`")->fetchAll(); } catch (PDOException $e) { echo " ! $t.$idxCol: {$e->getMessage()}\n"; continue; } $stale = $missing = 0; foreach ($rows as $r) { $p = $writer->decryptData($r[$srcCol] ?? null); if ($p === false || $p === '' || isSentinel($p)) continue; $exp = $blind->index($scope, $p); if ($exp === null) continue; if (empty($r[$idxCol])) $missing++; elseif ($r[$idxCol] !== $exp) $stale++; } $bad += $stale + $missing; printf(" %-26s missing=%-5d stale=%d\n", "$t.$idxCol", $missing, $stale); } echo $bad === 0 ? "\n✔ verification passed.\n" : "\n✘ $bad problem(s) found.\n"; exit($bad === 0 ? 0 : 1); } // ============================================================ // 7) الترحيل // ============================================================ echo $dryRun ? "── DRY RUN — nothing will be written ──\n" : "── Migrating v2 → v3 (CBC → GCM) ──\n"; echo " source: $srcName\n\n"; $grand = ['read' => 0, 'written' => 0, 'skipped' => 0, 'failed' => 0]; foreach ($MAP as $table => $spec) { if ($onlyTable && $onlyTable !== $table) continue; // الجداول ذات opt_in لا تُرحَّل ضمن التشغيل الكامل — تحتاج --table صراحةً if (!$onlyTable && !empty($spec['opt_in'])) { printf("[%s] skipped (session/OTP table — pass --table=%s to migrate it)\n", $table, $table); continue; } echo "[$table]\n"; try { $dstCols = targetColumns($dst, $table); } catch (PDOException $e) { echo " ✘ target table missing: {$e->getMessage()}\n\n"; continue; } $sql = "SELECT * FROM `$table`" . ($limit ? " LIMIT $limit" : ''); try { $rows = $src->query($sql)->fetchAll(); } catch (PDOException $e) { echo " ✘ source read failed: {$e->getMessage()}\n\n"; continue; } $total = count($rows); echo " source rows: $total\n"; if ($total === 0) { echo "\n"; continue; } if (isset($opt['truncate']) && !$dryRun) { $dst->exec("DELETE FROM `$table`"); echo " target emptied\n"; } // المفاتيح الموجودة مسبقاً — يجعل السكربت قابلاً لإعادة التشغيل $pk = $spec['pk']; $existing = []; if (isset($dstCols[$pk])) { foreach ($dst->query("SELECT `$pk` FROM `$table`") as $r) $existing[(string) $r[$pk]] = true; } $written = $skipped = $failed = 0; $unmapped = []; foreach (array_chunk($rows, $batch) as $chunk) { if (!$dryRun) $dst->beginTransaction(); foreach ($chunk as $row) { if (isset($existing[(string) ($row[$pk] ?? '')]) && !isset($opt['force'])) { $skipped++; continue; } $out = []; $plainMap = []; // القيَم الصريحة، لحساب الفهارس بعد التشفير // (أ) أعمدة تُنقل كما هي foreach (array_merge($spec['copy'] ?? [], $spec['copy_as_is'] ?? []) as $c) { if (array_key_exists($c, $row) && isset($dstCols[$c])) $out[$c] = $row[$c]; } // (ب) أعمدة تُفكّ ويُعاد تشفيرها $rowFailed = false; foreach ($spec['encrypt'] ?? [] as $c) { if (!array_key_exists($c, $row) || !isset($dstCols[$c])) continue; $raw = $row[$c]; if ($raw === null) { $out[$c] = null; continue; } if (isSentinel($raw)) { $out[$c] = $raw; $plainMap[$c] = null; continue; } [$plain, $fmt] = $legacy->decrypt((string) $raw); if ($fmt === 'fail') { // لا نكتب قيمة لا نفهمها ولا نُسقط الصف بصمت $rowFailed = true; break; } $plainMap[$c] = $plain; $out[$c] = $plain === '' ? '' : $writer->encryptDataGCM($plain); } if ($rowFailed) { $failed++; continue; } // (ج) أعمدة تتحوّل إلى مفتاح OTP الحتمي foreach ($spec['otp_key'] ?? [] as $c => $scope) { if (!array_key_exists($c, $row) || !isset($dstCols[$c])) continue; $raw = $row[$c]; if (isSentinel($raw)) { $out[$c] = $raw; continue; } [$plain, $fmt] = $legacy->decrypt((string) $raw); if ($fmt === 'fail') { $rowFailed = true; break; } $plainMap[$c] = $plain; $out[$c] = $plain === '' ? '' : otpKeyOf($blind, $scope, $plain); } if ($rowFailed) { $failed++; continue; } // (د) الأعمدة المشتقّة foreach ($spec['derive'] ?? [] as $col => [$kind, $scope, $sources]) { if (!isset($dstCols[$col])) { $unmapped[$col] = true; continue; } $parts = []; foreach ($sources as $s) { $v = $plainMap[$s] ?? null; if ($v !== null && $v !== '') $parts[] = $v; } if (!$parts) { $out[$col] = null; continue; } $value = implode(' ', $parts); $out[$col] = match ($kind) { 'bidx' => $blind->index($scope, $value), 'otpkey' => otpKeyOf($blind, $scope, $value), 'sha256' => hash('sha256', $value), }; } if ($dryRun) { $written++; continue; } $cols = array_keys($out); $stmt = $dst->prepare(sprintf( 'INSERT INTO `%s` (%s) VALUES (%s)', $table, implode(',', array_map(fn($c) => "`$c`", $cols)), implode(',', array_fill(0, count($cols), '?')) )); try { $stmt->execute(array_values($out)); $written++; } catch (PDOException $e) { $failed++; error_log("[migrate_v2] $table pk={$row[$pk]}: " . $e->getMessage()); } } if (!$dryRun) $dst->commit(); usleep(30_000); echo " … $written/$total\n"; } if ($unmapped) { echo " ! target is missing derived column(s): " . implode(', ', array_keys($unmapped)) . " → run migrations/2026_07_29_v2_migration_schema_fixes.sql first\n"; } printf(" ✔ written=%d skipped(existing)=%d failed=%d\n\n", $written, $skipped, $failed); $grand['read'] += $total; $grand['written'] += $written; $grand['skipped'] += $skipped; $grand['failed'] += $failed; } printf("──────────────\nread=%d written=%d skipped=%d failed=%d\n", $grand['read'], $grand['written'], $grand['skipped'], $grand['failed']); printf("legacy formats: cbc_fixed=%d cbc_random=%d gcm=%d plain=%d undecryptable=%d ambiguous=%d\n", $legacy->statFixedIv, $legacy->statRandomIv, $legacy->statGcm, $legacy->statPlain, $legacy->statFailed, $legacy->statAmbiguous); if ($dryRun) { echo "\n(dry run — re-run without --dry-run to apply, then --verify)\n"; } elseif ($grand['failed'] === 0) { echo "\n✔ done. Next: php migrate_v2_reencrypt.php --verify\n"; } else { echo "\n✘ {$grand['failed']} row(s) failed — see the PHP error log before switching traffic.\n"; }