From a1c19b052d66358141070bc5fc07bb62afda0a53 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 25 Jul 2026 16:18:16 +0300 Subject: [PATCH] Make OTP verification independent of the encryption mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verification tables (token_verification*, phone_verification*) use the phone number as a lookup key: written when the code is sent, read when it is checked. Storing it encrypted worked only because encryptData() is deterministic — under AES-GCM the two sides would produce different ciphertexts and no code would ever verify, locking every user out of registration and OTP sign-in. otpPhoneKey() stores a keyed HMAC of the normalised number instead. No schema change is needed since the column is textual, local and international formats now resolve to the same key, and the value cannot be reversed without the pepper. It falls back to the previous behaviour when no pepper is configured. Applied to both sides of every affected flow — request/verify, and the driver and passenger send/verify pairs — including the OTP value itself where it is compared by equality rather than decrypted. auth/otp/verify.php already decrypts the token before comparing, so it needed no change there. Also adds ENCRYPTION_MODE to EncryptionHelper: encryptData() writes GCM when set to 'gcm', CBC otherwise. Verified in both directions — rows written under CBC stay readable after switching, and rows written under GCM stay readable after rolling back — so the switch is reversible by an environment variable. The admin console's own OTP is unaffected: it keys the table by the stored ciphertext read from adminUser, identical on both sides. Co-Authored-By: Claude Opus 5 --- .../auth/driver/checkPhoneNumberISVerfied.php | 2 +- backend/auth/driver/isPhoneVerified.php | 2 +- backend/auth/driver/token/send_otp.php | 4 +- backend/auth/driver/token/verify_otp.php | 5 ++- backend/auth/otp/request.php | 2 +- backend/auth/otp/verify.php | 2 +- backend/auth/passenger/token/send_otp.php | 4 +- backend/auth/passenger/token/verify_otp.php | 5 ++- backend/core/Security/EncryptionHelper.php | 37 ++++++++++++++++++- backend/core/helpers.php | 25 +++++++++++++ 10 files changed, 74 insertions(+), 14 deletions(-) diff --git a/backend/auth/driver/checkPhoneNumberISVerfied.php b/backend/auth/driver/checkPhoneNumberISVerfied.php index a0b5d61b..c5a0867e 100644 --- a/backend/auth/driver/checkPhoneNumberISVerfied.php +++ b/backend/auth/driver/checkPhoneNumberISVerfied.php @@ -4,7 +4,7 @@ require_once __DIR__ . '/../../connect.php'; // استقبال وتشفير رقم الهاتف $phoneNumber = filterRequest("phone_number"); -$phoneNumber = $encryptionHelper->encryptData($phoneNumber); +$phoneNumber = otpPhoneKey($phoneNumber); // تجهيز الاستعلام باستخدام bindParam للحماية $sql = "SELECT * FROM `phone_verification` WHERE `phone_number` = :phone_number"; diff --git a/backend/auth/driver/isPhoneVerified.php b/backend/auth/driver/isPhoneVerified.php index 50dcacdd..5b0414a9 100644 --- a/backend/auth/driver/isPhoneVerified.php +++ b/backend/auth/driver/isPhoneVerified.php @@ -4,7 +4,7 @@ require_once __DIR__ . '/../../../connect.php'; $phoneNumber = filterRequest("phone_number"); // تشفير الرقم قبل البحث -$phoneNumber_encrypted = $encryptionHelper->encryptData($phoneNumber); +$phoneNumber_encrypted = otpPhoneKey($phoneNumber); try { // الاستعلام عن السائق حسب رقم الهاتف وحالة التحقق diff --git a/backend/auth/driver/token/send_otp.php b/backend/auth/driver/token/send_otp.php index 988d1ffd..a3a92ec8 100644 --- a/backend/auth/driver/token/send_otp.php +++ b/backend/auth/driver/token/send_otp.php @@ -51,8 +51,8 @@ $sentOK = ($httpCode === 200 && ($decoded['success'] ?? false)); if ($sentOK) { /* 3) تشفير البيانات وحفظها في DB ----------------------------------- */ - $receiver_enc = $encryptionHelper->encryptData($receiver); - $otp_enc = $encryptionHelper->encryptData($otp); + $receiver_enc = otpPhoneKey($receiver); + $otp_enc = otpPhoneKey($otp); // يجب أن يطابق صيغة المقارنة في verify_otp $exp = date('Y-m-d H:i:s', strtotime('+5 minutes')); $now = date('Y-m-d H:i:s'); diff --git a/backend/auth/driver/token/verify_otp.php b/backend/auth/driver/token/verify_otp.php index d8502918..7b04c0f2 100644 --- a/backend/auth/driver/token/verify_otp.php +++ b/backend/auth/driver/token/verify_otp.php @@ -9,8 +9,9 @@ if (empty($phoneNumber) || empty($otp)) { exit(); } -$phoneNumber_encrypted = $encryptionHelper->encryptData($phoneNumber); -$otp_encrypted = $encryptionHelper->encryptData($otp); +$phoneNumber_encrypted = otpPhoneKey($phoneNumber); +// الرمز يُقارن بالتساوي أيضاً، فيحتاج نفس الصيغة الثابتة +$otp_encrypted = otpPhoneKey($otp); try { $stmt = $con->prepare(" diff --git a/backend/auth/otp/request.php b/backend/auth/otp/request.php index b508ca21..1b0afc6c 100644 --- a/backend/auth/otp/request.php +++ b/backend/auth/otp/request.php @@ -119,7 +119,7 @@ switch (strtolower($country)) { // 6. DB Storage on Success if ($sentSuccessfully) { - $encryptedPhone = $encryptionHelper->encryptData($receiver); // Deterministic CBC + $encryptedPhone = otpPhoneKey($receiver); // مفتاح بحث ثابت مستقل عن نمط التشفير $encryptedOtp = $encryptionHelper->encryptDataGCM($otp); // Random GCM $encryptedEmail = !empty($email) ? $encryptionHelper->encryptData($email) : ''; diff --git a/backend/auth/otp/verify.php b/backend/auth/otp/verify.php index 63992771..a6d5b826 100644 --- a/backend/auth/otp/verify.php +++ b/backend/auth/otp/verify.php @@ -59,7 +59,7 @@ try { // 3. Encrypt data to query // 4. Verify based on user type try { - $encryptedPhoneSearch = $encryptionHelper->encryptData($phone_number); + $encryptedPhoneSearch = otpPhoneKey($phone_number); if ($user_type === 'admin') { $sql = "SELECT * FROM token_verification_admin diff --git a/backend/auth/passenger/token/send_otp.php b/backend/auth/passenger/token/send_otp.php index 5d0d421c..37e5a50c 100644 --- a/backend/auth/passenger/token/send_otp.php +++ b/backend/auth/passenger/token/send_otp.php @@ -51,8 +51,8 @@ $sentOK = ($httpCode === 200 && ($decoded['success'] ?? false)); if ($sentOK) { /* 3) حفظ الرمز في Redis + قاعدة البيانات */ - $receiver_enc = $encryptionHelper->encryptData($receiver); - $otp_enc = $encryptionHelper->encryptData($otp); + $receiver_enc = otpPhoneKey($receiver); + $otp_enc = otpPhoneKey($otp); // يجب أن يطابق صيغة المقارنة في verify_otp $exp = date('Y-m-d H:i:s', strtotime('+5 minutes')); $now = date('Y-m-d H:i:s'); diff --git a/backend/auth/passenger/token/verify_otp.php b/backend/auth/passenger/token/verify_otp.php index 72d704e7..3b0b6c26 100644 --- a/backend/auth/passenger/token/verify_otp.php +++ b/backend/auth/passenger/token/verify_otp.php @@ -18,8 +18,9 @@ if (empty($phoneNumber) || empty($otp)) { exit(); } -$phoneNumber_encrypted = $encryptionHelper->encryptData($phoneNumber); -$otp_encrypted = $encryptionHelper->encryptData($otp); +$phoneNumber_encrypted = otpPhoneKey($phoneNumber); +// الرمز يُقارن بالتساوي أيضاً، فيحتاج نفس الصيغة الثابتة +$otp_encrypted = otpPhoneKey($otp); try { // 1. التحقق من Redis بدلاً من MySQL diff --git a/backend/core/Security/EncryptionHelper.php b/backend/core/Security/EncryptionHelper.php index 3771e4ef..fee222a0 100644 --- a/backend/core/Security/EncryptionHelper.php +++ b/backend/core/Security/EncryptionHelper.php @@ -14,7 +14,16 @@ class EncryptionHelper private const TAG_LEN = 16; private const PREFIX_GCM = 'GCM:'; // للتمييز بين الجديد والقديم - public function __construct(string $key, ?string $cbcIv = null) + /** + * وضع الكتابة: 'cbc' (افتراضي) أو 'gcm'. + * + * القراءة غير متأثرة بهذا الوضع إطلاقاً — decryptData تتعرّف على الصيغتين + * عبر البادئة، فالسجلات القديمة تبقى مقروءة بلا ترحيل، والرجوع عن التحويل + * لا يُفقد أي سجل كُتب بـ GCM. + */ + private string $writeMode; + + public function __construct(string $key, ?string $cbcIv = null, ?string $writeMode = null) { if (strlen($key) !== 32) { throw new InvalidArgumentException('Encryption key must be exactly 32 bytes.'); @@ -22,10 +31,34 @@ class EncryptionHelper $this->key = $key; // IV القديم للتوافقية أثناء مرحلة المايغريشن $this->cbcIv = $cbcIv ?: getenv('initializationVector') ?: str_repeat('0', 16); + + $mode = strtolower($writeMode ?: (getenv('ENCRYPTION_MODE') ?: 'cbc')); + $this->writeMode = $mode === 'gcm' ? 'gcm' : 'cbc'; } - // ─── تشفير نص باستخدام AES-256-CBC الحتمي ── + public function writeMode(): string + { + return $this->writeMode; + } + + /** + * نقطة التشفير الموحّدة لكل التطبيق. + * + * حتى الآن كانت CBC بـ IV ثابت، أي حتمية: نفس النص ينتج نفس التشفير، وهو + * ما كان يسمح بالبحث عبر مقارنة النص المشفّر، لكنه يسرّب المساواة + * والبادئات المشتركة. مع ENCRYPTION_MODE=gcm يصبح التشفير عشوائياً + * وموثَّقاً، ويتكفّل الفهرس الأعمى (BlindIndex) بالبحث. + */ public function encryptData(string $plainText): string + { + if ($this->writeMode === 'gcm') { + return $this->encryptDataGCM($plainText); + } + return $this->encryptDataCBC($plainText); + } + + // ─── تشفير نص باستخدام AES-256-CBC الحتمي (للتوافقية والرجوع) ── + public function encryptDataCBC(string $plainText): string { $plainText = mb_convert_encoding($plainText, 'UTF-8'); $padded = $this->addPadding($plainText); diff --git a/backend/core/helpers.php b/backend/core/helpers.php index 91b06f58..425dfcf2 100644 --- a/backend/core/helpers.php +++ b/backend/core/helpers.php @@ -38,6 +38,31 @@ function filterRequest(string $name, string $type = 'string'): mixed }; } + +/** + * مفتاح بحث ثابت لجداول التحقق (token_verification*, phone_verification*). + * + * هذه الجداول تستخدم رقم الهاتف كمفتاح بحث لا كبيان يُعرض: يُكتب عند الإرسال + * ويُقرأ عند التحقق. تخزينه مشفّراً كان يعمل فقط لأن التشفير حتمي — ومع + * AES-GCM العشوائي يُنتج الإرسال والتحقق قيمتين مختلفتين فلا ينجح أي رمز. + * + * البديل: بصمة HMAC حتمية للرقم بعد تطبيعه. لا تحتاج تعديل المخطط (العمود + * نصي أصلاً)، وتوحّد صيغ الرقم المحلية والدولية، ولا يمكن عكسها بلا المفتاح. + */ +function otpPhoneKey(?string $phone): string +{ + if ($phone === null || trim($phone) === '') return ''; + + global $blindIndex, $encryptionHelper; + + if ($blindIndex) { + return 'K:' . $blindIndex->index('otp.phone', $phone); + } + + // بلا BLIND_INDEX_PEPPER نعود للسلوك القديم حتى لا يتعطل التحقق + return $encryptionHelper ? $encryptionHelper->encryptData($phone) : $phone; +} + // ── ردود JSON موحدة ───────────────────────────────────────── function jsonSuccess(mixed $data = null, string $message = 'success', int $code = 200): never {