From c9b4d14da6cfb3397ccf7563eb869f286e71ce8a Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 25 Jul 2026 16:04:40 +0300 Subject: [PATCH] Route account lookups through the blind index and keep it fresh on write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Admin/driver/updateDriverFromAdmin.php | 7 +++ backend/auth/driver/register.php | 33 ++++++++++-- backend/auth/login.php | 25 ++++++++- backend/auth/passenger/register.php | 23 ++++++-- backend/ride/profile/update.php | 25 +++++++++ backend/scripts/backfill_blind_index.php | 52 ++++++++++++++++++- 6 files changed, 154 insertions(+), 11 deletions(-) diff --git a/backend/Admin/driver/updateDriverFromAdmin.php b/backend/Admin/driver/updateDriverFromAdmin.php index 0ad5a21a..71cce4af 100644 --- a/backend/Admin/driver/updateDriverFromAdmin.php +++ b/backend/Admin/driver/updateDriverFromAdmin.php @@ -24,6 +24,13 @@ if ($phone !== null && $phone !== '') { $encphone = $encryptionHelper->encryptData($phone); $updateFields[] = "`phone` = :phone"; $params[':phone'] = $encphone; + + // الفهرس يُحدَّث مع الرقم نفسه حتى لا يشير إلى القيمة القديمة + global $blindIndex; + if ($blindIndex) { + $updateFields[] = "`phone_bidx` = :phone_bidx"; + $params[':phone_bidx'] = $blindIndex->index('driver.phone', $phone); + } } if ($status !== null && $status !== '') { diff --git a/backend/auth/driver/register.php b/backend/auth/driver/register.php index 1e44eaa6..0af20a46 100644 --- a/backend/auth/driver/register.php +++ b/backend/auth/driver/register.php @@ -383,6 +383,16 @@ Therefore, do NOT assume a specific field is on the front or the back of a card. $pwdHashed = password_hash($rawSecret, PASSWORD_DEFAULT); /* ================== 4) Encrypt sensitive fields ================== */ + // فهارس البحث تُحسب من القيم الخام قبل التشفير — بعده تصبح القيمة الأصلية + // غير متاحة، وبعد الانتقال إلى GCM لا يمكن استنتاجها من النص المشفّر. + global $blindIndex; + $phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', $data['phone'] ?? null) : null; + $emailBidx = $blindIndex ? $blindIndex->index('driver.email', $data['email'] ?? null) : null; + $nameBidx = $blindIndex ? $blindIndex->index( + 'driver.name', + trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? '')) + ) : null; + $toEncryptDriver = [ "phone","email","first_name","last_name","name_arabic","gender", "national_number","address","site","fullNameMaritial","birthdate" @@ -402,8 +412,18 @@ Therefore, do NOT assume a specific field is on the front or the back of a card. $con->beginTransaction(); /* ================== 6) Check duplicate ================== */ - $dup = $con->prepare("SELECT id FROM driver WHERE phone = :p OR email = :e"); - $dup->execute([':p' => $data['phone'], ':e' => $data['email']]); + $dup = $con->prepare( + "SELECT id FROM driver + WHERE phone = :p OR email = :e + OR (:pb IS NOT NULL AND phone_bidx = :pb) + OR (:eb IS NOT NULL AND email_bidx = :eb)" + ); + $dup->execute([ + ':p' => $data['phone'], + ':e' => $data['email'], + ':pb' => $phoneBidx, + ':eb' => $emailBidx, + ]); if ($dup->rowCount() > 0) { $con->rollBack(); jsonError("Phone or email already registered."); @@ -418,14 +438,16 @@ Therefore, do NOT assume a specific field is on the front or the back of a card. address, licenseIssueDate, status, birthdate, site, first_name, last_name, accountBank, bankCode, employmentType, ai_data, user_input, maritalStatus, - fullNameMaritial, expirationDate, created_at, updated_at + fullNameMaritial, expirationDate, created_at, updated_at, + phone_bidx, email_bidx, name_bidx ) VALUES ( :id, :phone, :email, :pwd, :gender, :license_type, :national_number, :name_arabic, :issue_date, :expiry_date, :license_categories, :address, :licenseIssueDate, :status, :birthdate, :site, :first_name, :last_name, :accountBank, :bankCode, :employmentType, :ai_data, :user_input, :maritalStatus, - :fullNameMaritial, :expirationDate, NOW(), NOW() + :fullNameMaritial, :expirationDate, NOW(), NOW(), + :phone_bidx, :email_bidx, :name_bidx ) "; $insD = $con->prepare($sqlDriver); @@ -456,6 +478,9 @@ Therefore, do NOT assume a specific field is on the front or the back of a card. ':maritalStatus' => !empty($data['maritalStatus']) ? $data['maritalStatus'] : 'yet', ':fullNameMaritial' => !empty($data['fullNameMaritial']) ? $data['fullNameMaritial'] : 'yet', ':expirationDate' => !empty($data['expirationDate']) ? $data['expirationDate'] : 'yet', + ':phone_bidx' => $phoneBidx, + ':email_bidx' => $emailBidx, + ':name_bidx' => $nameBidx, ]); if (!$okD) { $con->rollBack(); diff --git a/backend/auth/login.php b/backend/auth/login.php index 82a43005..636f8253 100644 --- a/backend/auth/login.php +++ b/backend/auth/login.php @@ -11,16 +11,39 @@ if (empty($phone) && empty($email)) { exit; } -// Build WHERE dynamically: support phone-only, email-only, or both +/** + * البحث عن الحساب. + * + * سابقاً كان يقارن القيمة الخام بالعمود المشفّر مباشرةً، وهو ما ينجح فقط لأن + * التشفير الحالي حتمي (CBC بـ IV ثابت). الفهرس الأعمى يجعل هذا الاستعلام + * مستقلاً عن أسلوب التشفير، فلا ينكسر تسجيل الدخول عند الانتقال إلى AES-GCM. + * + * تُبقى المقارنتان القديمتان في نفس الشرط كاحتياط للحسابات التي لم تُفهرس بعد. + */ +global $blindIndex; + $conditions = []; $params = [':password' => $password]; + if (!empty($phone)) { $conditions[] = "passengers.phone = :phone"; $params[':phone'] = $phone; + + $phoneBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phone) : null; + if ($phoneBidx) { + $conditions[] = "passengers.phone_bidx = :phone_bidx"; + $params[':phone_bidx'] = $phoneBidx; + } } if (!empty($email)) { $conditions[] = "passengers.email = :email"; $params[':email'] = $email; + + $emailBidx = $blindIndex ? $blindIndex->index('passengers.email', $email) : null; + if ($emailBidx) { + $conditions[] = "passengers.email_bidx = :email_bidx"; + $params[':email_bidx'] = $emailBidx; + } } $where = implode(' OR ', $conditions); diff --git a/backend/auth/passenger/register.php b/backend/auth/passenger/register.php index 6c423c8b..ed1290a7 100644 --- a/backend/auth/passenger/register.php +++ b/backend/auth/passenger/register.php @@ -103,8 +103,17 @@ try { // Step 6: التحقق من وجود المستخدم (Database Check) // ====================================================== $step = 6; - $checkStmt = $con->prepare("SELECT id FROM passengers WHERE phone = ?"); - $checkStmt->execute([$phoneNumber_encrypted]); + // كشف التكرار عبر الفهرس الأعمى + المقارنة القديمة: بدون الفهرس يفشل + // الكشف بعد الانتقال إلى GCM فيُسمح بتسجيل نفس الرقم مرتين. + global $blindIndex; + $phoneBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phoneNumber) : null; + $emailBidx = $blindIndex ? $blindIndex->index('passengers.email', $email) : null; + $nameBidx = $blindIndex ? $blindIndex->index('passengers.name', trim("$firstName $lastName")) : null; + + $checkStmt = $con->prepare( + "SELECT id FROM passengers WHERE phone = ? OR (? IS NOT NULL AND phone_bidx = ?)" + ); + $checkStmt->execute([$phoneNumber_encrypted, $phoneBidx, $phoneBidx]); if ($checkStmt->rowCount() > 0) { error_log("$logTag Step 6 Error: User already exists."); @@ -119,8 +128,8 @@ try { error_log("$logTag Step 7: Inserting into passengers table..."); $insertStmt = $con->prepare(" - INSERT INTO passengers (id, first_name, last_name, email, phone, password, gender, birthdate, site, sosPhone, education, employmentType, maritalStatus, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NOW(), NOW()) + INSERT INTO passengers (id, first_name, last_name, email, phone, password, gender, birthdate, site, sosPhone, education, employmentType, maritalStatus, status, created_at, updated_at, phone_bidx, email_bidx, name_bidx) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NOW(), NOW(), ?, ?, ?) "); $success = $insertStmt->execute([ $uniqueId, @@ -135,7 +144,11 @@ try { $unknown_encrypted, $unknown_encrypted, $unknown_encrypted, - $unknown_encrypted + $unknown_encrypted, + // فهارس البحث: تُكتب مع السجل حتى يكون قابلاً للبحث فوراً + $phoneBidx, + $emailBidx, + $nameBidx ]); if (!$success) { diff --git a/backend/ride/profile/update.php b/backend/ride/profile/update.php index 2cab13fa..d23d4077 100644 --- a/backend/ride/profile/update.php +++ b/backend/ride/profile/update.php @@ -11,15 +11,40 @@ $encryptedFields = [ "first_name", "last_name", "education", "employmentType", "maritalStatus" ]; +// أي تعديل على حقل مفهرس يجب أن يُحدّث فهرسه في نفس العبارة، وإلا بقي +// الفهرس يشير إلى القيمة القديمة وأصبح البحث يعطي نتيجة خاطئة. +global $blindIndex; +$plain = []; + foreach ($encryptedFields as $field) { if (isset($_POST[$field]) && !empty($_POST[$field])) { $value = filterRequest($field); + $plain[$field] = $value; $encryptedValue = $encryptionHelper->encryptData($value); $fields[] = "`$field` = :$field"; $params[":$field"] = $encryptedValue; } } +if ($blindIndex) { + if (isset($plain['phone'])) { + $fields[] = "`phone_bidx` = :phone_bidx"; + $params[':phone_bidx'] = $blindIndex->index('passengers.phone', $plain['phone']); + } + if (isset($plain['first_name']) || isset($plain['last_name'])) { + // الاسم مركّب من عمودين: نقرأ الجزء غير المُعدَّل من السجل الحالي + $current = $con->prepare("SELECT first_name, last_name FROM passengers WHERE id = :id"); + $current->execute([':id' => $id]); + $row = $current->fetch(PDO::FETCH_ASSOC) ?: []; + + $first = $plain['first_name'] ?? $encryptionHelper->decryptData($row['first_name'] ?? null) ?: ''; + $last = $plain['last_name'] ?? $encryptionHelper->decryptData($row['last_name'] ?? null) ?: ''; + + $fields[] = "`name_bidx` = :name_bidx"; + $params[':name_bidx'] = $blindIndex->index('passengers.name', trim("$first $last")); + } +} + if (!empty($fields)) { $setClause = implode(", ", $fields); $sql = "UPDATE `passengers` SET $setClause WHERE `id` = :id"; diff --git a/backend/scripts/backfill_blind_index.php b/backend/scripts/backfill_blind_index.php index b87bfb9a..8e7d8cd8 100644 --- a/backend/scripts/backfill_blind_index.php +++ b/backend/scripts/backfill_blind_index.php @@ -25,7 +25,7 @@ if (PHP_SAPI !== 'cli') { require_once __DIR__ . '/../core/bootstrap.php'; require_once __DIR__ . '/../core/Security/BlindIndex.php'; -$options = getopt('', ['dry-run', 'force', 'verify::', 'table::', 'batch::']); +$options = getopt('', ['dry-run', 'force', 'audit', 'verify::', 'table::', 'batch::']); $dryRun = isset($options['dry-run']); $force = isset($options['force']); $only = $options['table'] ?? null; @@ -79,6 +79,56 @@ if (isset($options['verify'])) { 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']],