Storing the verification phone as a keyed HMAC fixed OTP lookups but broke every query that joined those tables back to the account, because phone_verification*.phone_number no longer holds the same value as driver.phone / passengers.phone. Six joins were affected, and four of them feed the `verified` flag that the rider and driver apps check at sign-in — so this was already failing under the current CBC mode, not only after a switch to GCM. Accounts now carry phone_key, computed exactly as otpPhoneKey() does, and the joins match on it. It is written at registration for both apps and populated for existing rows by the backfill. The backfill also covers the columns added for the remaining lookups: users.email_bidx/phone_bidx and driver.national_bidx, which were migrated but never populated, and honours a per-field prefix so phone_key reproduces otpPhoneKey's exact output. Insert column/value counts verified with a paren-aware parser after editing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
240 lines
8.9 KiB
PHP
240 lines
8.9 KiB
PHP
<?php
|
|
/**
|
|
* scripts/backfill_blind_index.php
|
|
*
|
|
* يملأ أعمدة الفهرس الأعمى للسجلات الموجودة.
|
|
*
|
|
* الاستخدام:
|
|
* php backfill_blind_index.php # تشغيل فعلي
|
|
* php backfill_blind_index.php --dry-run # عرض ما سيحدث دون كتابة
|
|
* php backfill_blind_index.php --table=driver --batch=200
|
|
*
|
|
* آمن للتشغيل والخدمة تعمل:
|
|
* - لا يقرأ ولا يكتب أي عمود مشفّر، يكتب أعمدة الفهرس فقط.
|
|
* - يعمل على دفعات مع فاصل قصير حتى لا يضغط القاعدة.
|
|
* - قابل لإعادة التشغيل: يتخطى الصفوف المملوءة ما لم يُمرَّر --force.
|
|
*/
|
|
|
|
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';
|
|
|
|
$options = getopt('', ['dry-run', 'force', 'audit', 'verify::', 'table::', 'batch::']);
|
|
$dryRun = isset($options['dry-run']);
|
|
$force = isset($options['force']);
|
|
$only = $options['table'] ?? null;
|
|
$batch = max(50, (int) ($options['batch'] ?? 500));
|
|
|
|
/** @var EncryptionHelper $encryptionHelper */
|
|
global $encryptionHelper;
|
|
|
|
try {
|
|
$blind = new BlindIndex();
|
|
} catch (RuntimeException $e) {
|
|
exit("✘ " . $e->getMessage() . "\n");
|
|
}
|
|
|
|
$con = Database::get('main');
|
|
|
|
|
|
/**
|
|
* --verify=<قيمة>: يبحث عن رقم/بريد عبر الفهرس ويطبع النتيجة، للتأكد من أن
|
|
* البحث يعمل فعلاً بعد التعبئة دون فتح واجهة أو قاعدة بيانات.
|
|
* لا يطبع أي بيانات حساسة كاملة.
|
|
*/
|
|
if (isset($options['verify'])) {
|
|
$needle = (string) $options['verify'];
|
|
if ($needle === '') exit("Usage: --verify=0791234567\n");
|
|
|
|
$con = Database::get('main');
|
|
$found = false;
|
|
|
|
foreach ([['driver', 'driver.phone', 'phone_bidx'],
|
|
['driver', 'driver.email', 'email_bidx'],
|
|
['passengers', 'passengers.phone', 'phone_bidx'],
|
|
['passengers', 'passengers.email', 'email_bidx']] as [$table, $scope, $column]) {
|
|
$idx = $blind->index($scope, $needle);
|
|
if (!$idx) continue;
|
|
|
|
$st = $con->prepare("SELECT id FROM `$table` WHERE `$column` = ? LIMIT 5");
|
|
$st->execute([$idx]);
|
|
$ids = $st->fetchAll(PDO::FETCH_COLUMN);
|
|
|
|
if ($ids) {
|
|
$found = true;
|
|
echo "✔ $table via $column → " . count($ids) . " match(es): " . implode(', ', $ids) . "\n";
|
|
}
|
|
}
|
|
|
|
if (!$found) {
|
|
echo "✘ no match for that value in any indexed column.\n";
|
|
echo " Check that the value is correct and that the backfill has run.\n";
|
|
}
|
|
exit;
|
|
}
|
|
|
|
|
|
/**
|
|
* --audit: يقارن كل فهرس مخزَّن بالفهرس المحسوب من القيمة المشفّرة.
|
|
*
|
|
* الهدف كشف "الانحراف": صف عُدِّل هاتفه أو بريده عبر مسار كتابة لا يُحدّث
|
|
* الفهرس، فيصبح البحث يجد السجل بقيمته القديمة أو لا يجده إطلاقاً. الانحراف
|
|
* صامت بطبيعته، ولن يظهر إلا حين يفشل بحث حقيقي.
|
|
*/
|
|
if (isset($options['audit'])) {
|
|
$con = Database::get('main');
|
|
$checks = [
|
|
['driver', 'driver.phone', 'phone', 'phone_bidx'],
|
|
['driver', 'driver.email', 'email', 'email_bidx'],
|
|
['passengers', 'passengers.phone', 'phone', 'phone_bidx'],
|
|
['passengers', 'passengers.email', 'email', 'email_bidx'],
|
|
['adminUser', 'adminUser.phone', 'phone', 'phone_bidx'],
|
|
];
|
|
|
|
$problems = 0;
|
|
foreach ($checks as [$table, $scope, $source, $column]) {
|
|
try {
|
|
$rows = $con->query("SELECT id, `$source`, `$column` FROM `$table`")->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
echo " ! $table skipped: " . $e->getMessage() . "\n";
|
|
continue;
|
|
}
|
|
|
|
$stale = $missing = 0;
|
|
foreach ($rows as $row) {
|
|
$plain = $encryptionHelper->decryptData($row[$source] ?? null);
|
|
if ($plain === false || $plain === '') continue;
|
|
|
|
$expected = $blind->index($scope, $plain);
|
|
if ($expected === null) continue;
|
|
|
|
if (empty($row[$column])) $missing++;
|
|
elseif ($row[$column] !== $expected) $stale++;
|
|
}
|
|
|
|
$problems += $stale + $missing;
|
|
$verdict = ($stale + $missing) === 0 ? 'ok' : "MISSING=$missing STALE=$stale";
|
|
echo sprintf(" %-24s %s\n", "$table.$column", $verdict);
|
|
}
|
|
|
|
echo $problems === 0
|
|
? "\n✔ every index matches its encrypted value.\n"
|
|
: "\n✘ $problems row(s) out of sync — re-run with --force to rebuild.\n";
|
|
exit($problems === 0 ? 0 : 1);
|
|
}
|
|
|
|
$targets = [
|
|
'driver' => [
|
|
'phone_bidx' => ['scope' => 'driver.phone', 'columns' => ['phone']],
|
|
'email_bidx' => ['scope' => 'driver.email', 'columns' => ['email']],
|
|
'name_bidx' => ['scope' => 'driver.name', 'columns' => ['first_name', 'last_name']],
|
|
'national_bidx' => ['scope' => 'driver.national', 'columns' => ['national_number']],
|
|
// نفس نطاق otpPhoneKey: يسمح بربط جداول التحقق بصاحب الحساب
|
|
'phone_key' => ['scope' => 'otp.phone', 'columns' => ['phone'], 'prefix' => 'K:'],
|
|
],
|
|
'passengers' => [
|
|
'phone_bidx' => ['scope' => 'passengers.phone', 'columns' => ['phone']],
|
|
'email_bidx' => ['scope' => 'passengers.email', 'columns' => ['email']],
|
|
'name_bidx' => ['scope' => 'passengers.name', 'columns' => ['first_name', 'last_name']],
|
|
'phone_key' => ['scope' => 'otp.phone', 'columns' => ['phone'], 'prefix' => 'K:'],
|
|
],
|
|
'adminUser' => [
|
|
'phone_bidx' => ['scope' => 'adminUser.phone', 'columns' => ['phone']],
|
|
'email_bidx' => ['scope' => 'adminUser.email', 'columns' => ['email']],
|
|
],
|
|
'users' => [
|
|
'phone_bidx' => ['scope' => 'users.phone', 'columns' => ['phone']],
|
|
'email_bidx' => ['scope' => 'users.email', 'columns' => ['email']],
|
|
],
|
|
];
|
|
|
|
echo $dryRun ? "── DRY RUN — nothing will be written ──\n" : "── Backfilling blind indexes ──\n";
|
|
|
|
foreach ($targets as $table => $fields) {
|
|
if ($only && $only !== $table) continue;
|
|
|
|
echo "\n[$table]\n";
|
|
|
|
$sourceColumns = [];
|
|
foreach ($fields as $spec) {
|
|
foreach ($spec['columns'] as $c) $sourceColumns[$c] = true;
|
|
}
|
|
$select = 'id, ' . implode(', ', array_keys($sourceColumns));
|
|
|
|
$where = $force ? '' : ' WHERE ' . implode(' OR ', array_map(
|
|
fn($f) => "`$f` IS NULL",
|
|
array_keys($fields)
|
|
));
|
|
|
|
try {
|
|
$rows = $con->query("SELECT $select FROM `$table`$where")->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
echo " ✘ skipped: " . $e->getMessage() . "\n";
|
|
continue;
|
|
}
|
|
|
|
$total = count($rows);
|
|
echo " rows to process: $total\n";
|
|
if ($total === 0) continue;
|
|
|
|
$updated = 0;
|
|
$failed = 0;
|
|
|
|
foreach (array_chunk($rows, $batch) as $chunk) {
|
|
if (!$dryRun) $con->beginTransaction();
|
|
|
|
foreach ($chunk as $row) {
|
|
$set = [];
|
|
$params = [':id' => $row['id']];
|
|
|
|
foreach ($fields as $column => $spec) {
|
|
// فك التشفير لقراءة القيمة الأصلية — القيمة المخزَّنة لا تتغير.
|
|
$parts = [];
|
|
foreach ($spec['columns'] as $src) {
|
|
$plain = $encryptionHelper->decryptData($row[$src] ?? null);
|
|
if ($plain === false) {
|
|
$failed++;
|
|
$parts = [];
|
|
break;
|
|
}
|
|
if ($plain !== '') $parts[] = $plain;
|
|
}
|
|
if (!$parts) continue;
|
|
|
|
$value = implode(' ', $parts);
|
|
$index = $blind->index($spec['scope'], $value);
|
|
if ($index === null) continue;
|
|
if (!empty($spec['prefix'])) $index = $spec['prefix'] . $index;
|
|
|
|
$set[] = "`$column` = :$column";
|
|
$params[":$column"] = $index;
|
|
}
|
|
|
|
if (!$set) continue;
|
|
|
|
if ($dryRun) {
|
|
$updated++;
|
|
continue;
|
|
}
|
|
|
|
$stmt = $con->prepare("UPDATE `$table` SET " . implode(', ', $set) . " WHERE id = :id");
|
|
$stmt->execute($params);
|
|
$updated++;
|
|
}
|
|
|
|
if (!$dryRun) $con->commit();
|
|
usleep(50_000); // نفس متعمّد حتى لا تُحتكر القاعدة أثناء الخدمة
|
|
echo " … $updated/$total\n";
|
|
}
|
|
|
|
echo " ✔ indexed: $updated" . ($failed ? " (undecryptable values skipped: $failed)" : '') . "\n";
|
|
}
|
|
|
|
echo "\nDone." . ($dryRun ? " (dry run — re-run without --dry-run to apply)" : '') . "\n";
|