Files
intaleq/backend/scripts/migrate_v2_reencrypt.php
T

620 lines
29 KiB
PHP

<?php
/**
* scripts/migrate_v2_reencrypt.php
*
* ترحيل بيانات intaleqDBV2 (التشفير القديم AES-256-CBC) إلى قاعدة v3
* (schema_primary.sql) بالتشفير الجديد AES-256-GCM + الفهارس العمياء.
*
* لماذا سكربت وليس mysqldump؟ لأن النص المشفّر نفسه هو ما يجب أن يتغيّر:
* كل قيمة تُفكّ بالمفتاح القديم ثم يُعاد تشفيرها عشوائياً، وتُشتقّ منها
* أعمدة جديدة (phone_bidx, name_bidx, phone_key, fingerprint_hash) لا وجود
* لها في المصدر ولا يمكن حسابها إلا والقيمة مكشوفة لحظةً واحدة داخل الذاكرة.
*
* ─── الاستخدام ───────────────────────────────────────────────
* php migrate_v2_reencrypt.php --dry-run # تحليل بلا كتابة (ابدأ من هنا)
* php migrate_v2_reencrypt.php --probe # فحص المفتاح على عيّنة فقط
* php migrate_v2_reencrypt.php --table=driver # جدول واحد
* php migrate_v2_reencrypt.php --truncate # تفريغ الهدف قبل الإدخال
* php migrate_v2_reencrypt.php # تنفيذ فعلي
* php migrate_v2_reencrypt.php --verify # تدقيق بعد الترحيل
*
* ─── متغيّرات البيئة المطلوبة ─────────────────────────────────
* المصدر (القديم): SRC_DB_HOST SRC_DB_NAME SRC_DB_USER SRC_DB_PASS
* الهدف (الجديد): DB_PRIMARY_*_V2 (عبر Database::get('main'))
* المفتاح: ENCRYPTION_KEY_PATH أو ENC_KEY (32 بايت — نفسه للقديم والجديد)
* الـ IV القديم: initializationVector (16 بايت — الثابت القديم)
* الفهرس الأعمى: BLIND_INDEX_PEPPER
*
* ملاحظة مهمة: هذا السكربت يفرض GCM على الكتابة بغضّ النظر عن
* ENCRYPTION_MODE، لأن الغاية منه هي التحويل نفسه.
*/
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("This script runs from the command line only.\n");
}
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/../core/Security/BlindIndex.php';
$opt = getopt('', ['dry-run', 'probe', 'verify', 'truncate', 'force',
'table::', 'limit::', 'batch::', 'legacy-format::']);
$dryRun = isset($opt['dry-run']);
$onlyTable = $opt['table'] ?? null;
$limit = isset($opt['limit']) ? max(1, (int) $opt['limit']) : 0;
$batch = max(50, (int) ($opt['batch'] ?? 500));
// ============================================================
// 1) فكّاك التشفير القديم
// ============================================================
/**
* قاعدة v2 تحوي *ثلاث* صيغ متداخلة، لأن ملفين مختلفين كانا يكتبان فيها:
*
* أ) CBC بـ IV ثابت، بلا بادئة → core/Security/EncryptionHelper::encryptDataCBC
* ب) CBC بـ IV عشوائي مُلحق في الأول → encrypt_decrypt.php::encryptData (الجذر)
* ج) نص عادي (yet / none / تواريخ / hex ids)
*
* الصيغة (ب) هي الفخّ: EncryptionHelper الجديد لا يعرفها إطلاقاً (يجرّب الـ IV
* الثابت فقط)، فلو رحّلنا اعتماداً عليه لخرجت الصفوف بقيَم false صامتة.
*
* ⚠️ الصيغتان غير قابلتين للتمييز بالحشو وحده: في CBC يؤثّر الـ IV على البلوك
* الأول فقط، فأي نصّ من بلوكين فأكثر يمرّ في المسارين بحشو صحيح — أحدهما
* يُنتج 16 بايت قمامة في البداية والآخر يبتر أول بلوك. لذلك:
* - نتحقّق من صلاحية UTF-8 (تُسقط القمامة في الغالب الأعمّ)، و
* - نعتمد صيغة مفضّلة صريحة (--legacy-format) بدل التخمين لكل قيمة، و
* - نعدّ الحالات الملتبسة ونعرضها حتى لا يمرّ خطأ صامت.
* الافتراضي `fixed` لأن بيانات v2 تُظهر نصوصاً مشفّرة متطابقة لنفس القيمة
* (نفس ciphertext للجنس/الموقع عبر آلاف الصفوف) وهذا برهان على IV ثابت.
*/
final class LegacyDecryptor
{
public int $statFixedIv = 0;
public int $statRandomIv = 0;
public int $statGcm = 0;
public int $statPlain = 0;
public int $statFailed = 0;
public int $statAmbiguous = 0;
/** @param string $prefer 'fixed'|'random' */
public function __construct(private string $key, private string $iv, private string $prefer = 'fixed') {}
/** @return array{0:string,1:string} [plaintext, format] — format ∈ cbc_fixed|cbc_random|gcm|plain|fail */
public function decrypt(?string $value): array
{
if ($value === null || $value === '') return ['', 'plain'];
// مشفّر بالنظام الجديد أصلاً (إعادة تشغيل السكربت مثلاً)
if (str_starts_with($value, 'GCM:')) {
global $encryptionHelper;
$p = $encryptionHelper->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";
}