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>
102 lines
4.3 KiB
PHP
102 lines
4.3 KiB
PHP
<?php
|
||
/**
|
||
* core/Security/BlindIndex.php
|
||
*
|
||
* فهرس أعمى للبحث فوق حقول مشفّرة.
|
||
*
|
||
* المشكلة: التشفير الآمن (AES-GCM) عشوائي — نفس النص ينتج تشفيراً مختلفاً في
|
||
* كل مرة، فلا يمكن البحث بمقارنة النص المشفّر. والحل القديم (CBC بـ IV ثابت)
|
||
* يجعل التشفير حتمياً فينجح البحث، لكنه يسرّب المساواة والبادئات المشتركة.
|
||
*
|
||
* الحل: نفصل التخزين عن البحث.
|
||
* - التخزين: AES-GCM عشوائي (لا يسرّب شيئاً).
|
||
* - البحث: عمود إضافي يحمل HMAC-SHA256 حتمياً للقيمة بعد تطبيعها.
|
||
*
|
||
* لماذا HMAC وليس sha256 عارياً؟ لأن مساحة أرقام الهواتف صغيرة (ملايين
|
||
* قليلة) — جدول عكسي لكل الأرقام يُبنى في ثوانٍ. المفتاح السرّي (pepper)
|
||
* المخزَّن في البيئة وحده يمنع ذلك، فمن يسرق قاعدة البيانات لا يملكه.
|
||
*/
|
||
|
||
final class BlindIndex
|
||
{
|
||
private string $pepper;
|
||
|
||
public function __construct(?string $pepper = null)
|
||
{
|
||
$pepper = $pepper ?: (getenv('BLIND_INDEX_PEPPER') ?: '');
|
||
if ($pepper === '') {
|
||
throw new RuntimeException(
|
||
'BLIND_INDEX_PEPPER is not set. Generate one with: openssl rand -hex 32'
|
||
);
|
||
}
|
||
$this->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);
|
||
}
|
||
}
|