Add blind-index search layer; fix captain detail 200-with-empty-body
Searching encrypted columns currently works only because encryptData() is AES-CBC with a fixed IV, i.e. deterministic. That determinism is what leaks equality and shared prefixes, and it is why moving storage to AES-GCM would break every lookup. This separates the two concerns. - core/Security/BlindIndex.php: HMAC-SHA256 over a normalised value, keyed by a secret pepper. Phone numbers have a small keyspace, so a bare SHA-256 would be reversible by enumeration; the pepper lives in the environment, not the database. The scope string includes table and field so the same number does not produce a matching index across tables. Normalisation unifies local/international phone forms, lowercases emails and folds Arabic alef/ya/ta-marbuta and diacritics for names. - migrations/: nullable *_bidx columns plus indexes, and the missing adminUser.status/approved_by/approved_at columns that admin approvals need. - scripts/backfill_blind_index.php: restartable, batched, --dry-run capable, touches only index columns. - Admin lookups by phone/email now match the index, keeping the old ciphertext comparison in the same query so search keeps working until the backfill runs. bootstrap exposes $blindIndex as null when no pepper is configured. Also: AdminCaptain/getCaptainDetailsById.php selected driver.education, a column absent from this schema. The PDOException was uncaught, so the client received an empty body with HTTP 200 — the "non-JSON response" seen when opening a captain. It now omits the column, catches the error, reports it as JSON, and requires an admin role. Console: opening any sidebar section refetches its data instead of showing what was loaded when the console started. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
81ee2acefd
commit
6802026dbd
@@ -0,0 +1,143 @@
|
||||
<?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', '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');
|
||||
|
||||
$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";
|
||||
Reference in New Issue
Block a user