These are the paths that must stop depending on deterministic encryption before storage can move to AES-GCM. Each keeps its original ciphertext comparison in the same statement, so behaviour is unchanged today and no account becomes unreachable during the transition. Lookups: - auth/login.php — passenger sign-in matched the raw value against the encrypted column, which only works because encryptData() is CBC with a fixed IV. - auth/passenger/register.php and auth/driver/register.php — duplicate detection. Without the index these would stop detecting existing accounts under GCM and allow the same phone to register twice. Writes now populate the index in the same statement as the value: - both registration paths write phone/email/name indexes with the row; driver indexes are computed before the encryption pass, since the raw values are unavailable afterwards. - passenger profile update and admin driver update refresh the index when the underlying field changes. For the composite name index the untouched half is read back from the row. Adds --audit to the backfill script: recomputes every index from its encrypted value and reports missing or stale entries. Drift here is silent by nature — it surfaces only when a real search fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
8.2 KiB
PHP
231 lines
8.2 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']],
|
|
],
|
|
'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']],
|
|
],
|
|
'adminUser' => [
|
|
'phone_bidx' => ['scope' => 'adminUser.phone', 'columns' => ['phone']],
|
|
'email_bidx' => ['scope' => 'adminUser.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;
|
|
|
|
$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";
|