diff --git a/backend/Admin/AdminCaptain/getCaptainDetailsByEmailOrIDOrPhone.php b/backend/Admin/AdminCaptain/getCaptainDetailsByEmailOrIDOrPhone.php index 5902c447..fbb03c02 100644 --- a/backend/Admin/AdminCaptain/getCaptainDetailsByEmailOrIDOrPhone.php +++ b/backend/Admin/AdminCaptain/getCaptainDetailsByEmailOrIDOrPhone.php @@ -5,6 +5,15 @@ $driver_id = filterRequest("driver_id"); $driverEmail = $encryptionHelper->encryptData(filterRequest("driverEmail")); $driverPhone = $encryptionHelper->encryptData(filterRequest("driverPhone")); + +/** + * الفهرس الأعمى: يسمح بالبحث بعد نقل التخزين إلى AES-GCM العشوائي. + * تُبقى المقارنة القديمة في نفس الاستعلام كاحتياط حتى تنتهي تعبئة الفهارس. + */ +global $blindIndex; +$emailBidx = $blindIndex ? $blindIndex->index('driver.email', filterRequest("driverEmail")) : null; +$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', filterRequest("driverPhone")) : null; + $sql = "SELECT `driver`.`id`, `driver`.`phone`, @@ -53,6 +62,8 @@ $sql = "SELECT ) AS passengerToken FROM `driver` WHERE `driver`.`email` = :email OR `driver`.`phone` = :phone OR `driver`.`id` = :id + OR (:email_bidx IS NOT NULL AND `driver`.`email_bidx` = :email_bidx) + OR (:phone_bidx IS NOT NULL AND `driver`.`phone_bidx` = :phone_bidx) ORDER BY passengerAverageRating DESC LIMIT 10 "; @@ -61,6 +72,8 @@ $stmt = $con->prepare($sql); $stmt->bindParam(":email", $driverEmail); $stmt->bindParam(":phone", $driverPhone); $stmt->bindParam(":id", $driver_id); +$stmt->bindParam(":email_bidx", $emailBidx); +$stmt->bindParam(":phone_bidx", $phoneBidx); $stmt->execute(); $result = $stmt->fetchAll(PDO::FETCH_ASSOC); diff --git a/backend/Admin/AdminCaptain/getCaptainDetailsById.php b/backend/Admin/AdminCaptain/getCaptainDetailsById.php index a9cb9850..6af3dc0f 100644 --- a/backend/Admin/AdminCaptain/getCaptainDetailsById.php +++ b/backend/Admin/AdminCaptain/getCaptainDetailsById.php @@ -1,9 +1,18 @@ 'Unauthorized: Admin access required']); + exit; +} + $driver_id = filterRequest("driver_id"); +if (empty($driver_id)) { + jsonError("driver_id is required", 400); +} + $sql = "SELECT `driver`.`id`, `driver`.`phone`, @@ -14,7 +23,6 @@ $sql = "SELECT `driver`.`site`, `driver`.`first_name`, `driver`.`last_name`, - `driver`.`education`, `driver`.`employmentType`, `driver`.`maritalStatus`, `driver`.`created_at`, @@ -59,14 +67,23 @@ WHERE `driver`.`id` = :driver_id ORDER BY passengerAverageRating DESC LIMIT 10"; -$stmt = $con->prepare($sql); -$stmt->bindParam(':driver_id', $driver_id); -$stmt->execute(); - -$result = $stmt->fetchAll(PDO::FETCH_ASSOC); +try { + $stmt = $con->prepare($sql); + $stmt->bindParam(':driver_id', $driver_id); + $stmt->execute(); + $result = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (PDOException $e) { + // بلا هذا الالتقاط كان الاستثناء يُنهي السكربت فيصل للعميل جسم فارغ + // بحالة HTTP 200، فيظهر كـ "رد غير JSON". + error_log("[getCaptainDetailsById] " . $e->getMessage()); + jsonError("Could not read the captain record: " . $e->getMessage(), 500); +} // فك تشفير الحقول الحساسة بعد الجلب foreach ($result as &$row) { + foreach (['phone','email','gender','birthdate','site','first_name','last_name','employmentType','maritalStatus'] as $f) { + if (!array_key_exists($f, $row)) $row[$f] = null; + } $row['phone'] = $encryptionHelper->decryptData($row['phone']); $row['email'] = $encryptionHelper->decryptData($row['email']); $row['gender'] = $encryptionHelper->decryptData($row['gender']); @@ -74,7 +91,6 @@ foreach ($result as &$row) { $row['site'] = $encryptionHelper->decryptData($row['site']); $row['first_name'] = $encryptionHelper->decryptData($row['first_name']); $row['last_name'] = $encryptionHelper->decryptData($row['last_name']); - $row['education'] = $encryptionHelper->decryptData($row['education']); $row['employmentType'] = $encryptionHelper->decryptData($row['employmentType']); $row['maritalStatus'] = $encryptionHelper->decryptData($row['maritalStatus']); } diff --git a/backend/Admin/driver/find_driver_by_phone.php b/backend/Admin/driver/find_driver_by_phone.php index 05fb7365..da0a547b 100644 --- a/backend/Admin/driver/find_driver_by_phone.php +++ b/backend/Admin/driver/find_driver_by_phone.php @@ -9,18 +9,34 @@ if (empty($phone)) { } try { - // تشفير الرقم المدخل للبحث - $encPhone = $encryptionHelper->encryptData($phone); + /** + * البحث عبر الفهرس الأعمى أولاً (phone_bidx): مطابقة تامة عبر فهرس مُهيأ + * ولا تعتمد على كون التشفير حتمياً، فتظل تعمل بعد النقل إلى AES-GCM. + * + * يُبقى المسار القديم (مقارنة النص المشفّر) كاحتياط حتى ينتهي تشغيل + * scripts/backfill_blind_index.php، وإلا لتوقّف البحث بين الترحيل والتعبئة. + */ + global $blindIndex; + $driver = null; - // احضار كل الأعمدة باستثناء كلمة المرور - $sql = "SELECT * - FROM driver - WHERE phone = :phone - LIMIT 1"; - $stmt = $con->prepare($sql); - $stmt->execute([':phone' => $encPhone]); + if ($blindIndex) { + $bidx = $blindIndex->index('driver.phone', $phone); + if ($bidx) { + $stmt = $con->prepare("SELECT * FROM driver WHERE phone_bidx = :bidx LIMIT 1"); + $stmt->execute([':bidx' => $bidx]); + $driver = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + } + } - $driver = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$driver) { + $encPhone = $encryptionHelper->encryptData($phone); + $stmt = $con->prepare("SELECT * FROM driver WHERE phone = :phone LIMIT 1"); + $stmt->execute([':phone' => $encPhone]); + } + + if (!$driver) { + $driver = $stmt->fetch(PDO::FETCH_ASSOC); + } if ($driver) { // ✅ الحقول المشفرة اللي لازم تنفك: diff --git a/backend/Admin/getPassengerbyEmail.php b/backend/Admin/getPassengerbyEmail.php index 0b31982c..7ff59ba0 100644 --- a/backend/Admin/getPassengerbyEmail.php +++ b/backend/Admin/getPassengerbyEmail.php @@ -5,6 +5,15 @@ $passengerEmail = $encryptionHelper->encryptData(filterRequest("passengerEmail") $passengerId = filterRequest("passengerId"); $passengerphone = $encryptionHelper->encryptData(filterRequest("passengerphone")); + +/** + * الفهرس الأعمى: يسمح بالبحث بعد نقل التخزين إلى AES-GCM العشوائي. + * تُبقى المقارنة القديمة في نفس الاستعلام كاحتياط حتى تنتهي تعبئة الفهارس. + */ +global $blindIndex; +$emailBidx = $blindIndex ? $blindIndex->index('passengers.email', filterRequest("passengerEmail")) : null; +$phoneBidx = $blindIndex ? $blindIndex->index('passengers.phone', filterRequest("passengerphone")) : null; + $sql = "SELECT `passengers`.`id`, `passengers`.`phone`, @@ -59,12 +68,16 @@ FROM `passengers` WHERE passengers.email = :email OR passengers.phone = :phone OR passengers.id = :id + OR (:email_bidx IS NOT NULL AND passengers.email_bidx = :email_bidx) + OR (:phone_bidx IS NOT NULL AND passengers.phone_bidx = :phone_bidx) "; $stmt = $con->prepare($sql); $stmt->bindParam(":email", $passengerEmail); $stmt->bindParam(":phone", $passengerphone); $stmt->bindParam(":id", $passengerId); +$stmt->bindParam(":email_bidx", $emailBidx); +$stmt->bindParam(":phone_bidx", $phoneBidx); $stmt->execute(); $result = $stmt->fetchAll(PDO::FETCH_ASSOC); diff --git a/backend/core/Security/BlindIndex.php b/backend/core/Security/BlindIndex.php new file mode 100644 index 00000000..0c7dffc0 --- /dev/null +++ b/backend/core/Security/BlindIndex.php @@ -0,0 +1,101 @@ +pepper = $pepper; + } + + /** + * يحسب الفهرس لقيمة داخل حقل محدد. + * + * $scope يشمل الجدول والحقل (مثل "driver.phone") عمداً: بدونه يكون فهرس + * نفس الرقم متطابقاً في جدول السائقين والركاب، فيستطيع من يقرأ القاعدة + * ربط الحسابات ببعضها دون فك أي تشفير. + */ + public function index(string $scope, ?string $value): ?string + { + $normalized = self::normalize($scope, $value); + if ($normalized === null || $normalized === '') { + return null; + } + return hash_hmac('sha256', $scope . ':' . $normalized, $this->pepper); + } + + /** + * فهرس مبتور للبحث الجزئي (مثل الأسماء). + * + * البتر مقصود: يُنتج تطابقات كاذبة تُصفّى بعد فك التشفير، وهذه الضبابية + * هي ما يمنع استخدام الفهرس نفسه في تحليل التكرارات. + */ + public function bucket(string $scope, ?string $value, int $length = 8): ?string + { + $full = $this->index($scope, $value); + return $full === null ? null : substr($full, 0, $length); + } + + /** + * التطبيع قبل الحساب — بدونه يُنتج 0791234567 و+962791234567 فهرسين + * مختلفين ويفشل البحث. + */ + public static function normalize(string $scope, ?string $value): ?string + { + if ($value === null) return null; + $value = trim($value); + if ($value === '') return null; + + if (str_contains($scope, 'phone')) { + $digits = preg_replace('/\D+/', '', $value); + // توحيد الصيغة المحلية والدولية على شكل واحد + $digits = preg_replace('/^00/', '', $digits); + if (str_starts_with($digits, '0')) { + $cc = getenv('DEFAULT_COUNTRY_CODE') ?: '962'; + $digits = $cc . substr($digits, 1); + } + return $digits; + } + + if (str_contains($scope, 'email')) { + return mb_strtolower($value, 'UTF-8'); + } + + // الأسماء: توحيد حالة الأحرف والمسافات، وتوحيد أشكال الألف والياء + // والتاء المربوطة العربية حتى لا يتوقف البحث على شكل الكتابة. + $value = mb_strtolower($value, 'UTF-8'); + $value = preg_replace('/\s+/u', ' ', $value); + $value = str_replace( + ['أ', 'إ', 'آ', 'ٱ', 'ى', 'ة', 'ؤ', 'ئ'], + ['ا', 'ا', 'ا', 'ا', 'ي', 'ه', 'و', 'ي'], + $value + ); + // إزالة التشكيل + $value = preg_replace('/[\x{064B}-\x{0652}\x{0640}]/u', '', $value); + return trim($value); + } +} diff --git a/backend/core/bootstrap.php b/backend/core/bootstrap.php index 7c241344..7b6c9c7b 100644 --- a/backend/core/bootstrap.php +++ b/backend/core/bootstrap.php @@ -157,6 +157,17 @@ try { // 5. تحميل الـ Services الأساسية require_once __DIR__ . '/Security/EncryptionHelper.php'; +require_once __DIR__ . '/Security/BlindIndex.php'; + +// فهرس البحث الأعمى — اختياري: إن لم يُضبط BLIND_INDEX_PEPPER تبقى نقاط +// البحث تعمل بأسلوبها القديم بدل أن تفشل. +$blindIndex = null; +try { + $blindIndex = new BlindIndex(); +} catch (Throwable $e) { + error_log('[BlindIndex] disabled: ' . $e->getMessage()); +} + require_once __DIR__ . '/Database/Database.php'; require_once __DIR__ . '/Auth/RateLimiter.php'; require_once __DIR__ . '/Auth/JwtService.php'; diff --git a/backend/migrations/2026_07_25_blind_index.sql b/backend/migrations/2026_07_25_blind_index.sql new file mode 100644 index 00000000..9a7a5892 --- /dev/null +++ b/backend/migrations/2026_07_25_blind_index.sql @@ -0,0 +1,40 @@ +-- ============================================================ +-- Blind index columns for searching encrypted fields +-- 2026-07-25 +-- +-- تُضاف أعمدة بحث حتمية (HMAC) بجانب الأعمدة المشفّرة، حتى يمكن نقل التخزين +-- إلى AES-GCM العشوائي دون فقدان القدرة على البحث. +-- +-- آمنة للتشغيل على قاعدة تعمل: كل الأعمدة NULL افتراضياً ولا يقرأها أي كود +-- قبل تشغيل سكربت التعبئة. +-- ============================================================ + +ALTER TABLE `driver` + ADD COLUMN `phone_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف', + ADD COLUMN `email_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد', + ADD COLUMN `name_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للاسم الكامل بعد التطبيع', + ADD INDEX `idx_driver_phone_bidx` (`phone_bidx`), + ADD INDEX `idx_driver_email_bidx` (`email_bidx`), + ADD INDEX `idx_driver_name_bidx` (`name_bidx`); + +ALTER TABLE `passengers` + ADD COLUMN `phone_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف', + ADD COLUMN `email_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد', + ADD COLUMN `name_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للاسم الكامل بعد التطبيع', + ADD INDEX `idx_passengers_phone_bidx` (`phone_bidx`), + ADD INDEX `idx_passengers_email_bidx` (`email_bidx`), + ADD INDEX `idx_passengers_name_bidx` (`name_bidx`); + +ALTER TABLE `adminUser` + ADD COLUMN `phone_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف', + ADD COLUMN `email_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد', + ADD INDEX `idx_adminuser_phone_bidx` (`phone_bidx`), + ADD INDEX `idx_adminuser_email_bidx` (`email_bidx`); + +-- عمود status مفقود في هذا النشر، وبدونه لا يمكن تتبّع موافقات المشرفين +-- (Admin/Staff/pending.php و auth/approve_admin.php يعتمدان عليه). +-- القيمة الافتراضية 'active' مقصودة حتى لا تُقفل الحسابات القائمة خارج النظام. +ALTER TABLE `adminUser` + ADD COLUMN `status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active | pending | suspended | rejected', + ADD COLUMN `approved_by` VARCHAR(32) NULL DEFAULT NULL, + ADD COLUMN `approved_at` TIMESTAMP NULL DEFAULT NULL; diff --git a/backend/scripts/backfill_blind_index.php b/backend/scripts/backfill_blind_index.php new file mode 100644 index 00000000..e0218dd0 --- /dev/null +++ b/backend/scripts/backfill_blind_index.php @@ -0,0 +1,143 @@ +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"; diff --git a/dashboard/siro-admin/index.html b/dashboard/siro-admin/index.html index c577ca52..1f80c863 100644 --- a/dashboard/siro-admin/index.html +++ b/dashboard/siro-admin/index.html @@ -15,7 +15,7 @@ - +
@@ -609,6 +609,6 @@ - +