HMAC التهم الإكمال التلقائي: البحث الاستباقي في عناوين البريد الإلكتروني المشفرة
نُشفّر بيانات المستخدمين الشخصية (PII) أثناء التخزين بمفاتيح خاصة بكل مستأجر، بحيث لا تكشف نسخة مسربة من قاعدة البيانات عن أي شيء مفيد. عمود البريد الإلكتروني نص مشفر. أرقام الهواتف والأسماء والسمات المخصصة: كلها نصوص مشفرة. نحن فخورون بهذا، فهو ميزة بيعية.
وفي اليوم الذي دخلت فيه الميزة حيز التشغيل، توقف مربع البحث في لوحة الإدارة بهدوء عن الإكمال التلقائي.
لا خطأ. ولا سطر في السجلات. اكتب ali في بحث المستخدمين، ومدير المستأجر الذي اعتاد أن يرى [email protected] يظهر بعد ثلاث ضغطات على المفاتيح صار الآن لا يرى... شيئًا، ما لم يكتب عنوان البريد الإلكتروني بأكمله حرفيًا. لم يتعطل البحث، بل تدهور بصمت من "يبدأ بـ" إلى "يساوي"، ولم يعتبر أي شيء في النظام ذلك أمرًا يستحق الذكر.
هذه قصة استعادة البحث الاستباقي فوق بيانات نرفض تخزينها كنص صريح، والفخاخ الثلاثة التي وقعنا فيها أثناء ذلك. وقد تبين أن التشفير كان الجزء السهل.
لماذا يلتهم التشفير الإكمال التلقائي
مع النص الصريح، البحث بالبادئة هو الغاية التي وُجدت قواعد البيانات لأجلها. احتفظ بفهرس مرتب حسب البريد الإلكتروني، ويصبح starts with "ali" مسحًا لنطاق: كل ما هو >= "ali" و < "alj". رخيص وبديهي ومنجز.
شفّر العمود ويختفي الفهرس المرتب. البديل القياسي هو الفهرس الأعمى (blind index): إلى جانب النص المشفر، خزّن HMAC بمفتاح للقيمة، وابحث عن المستخدمين بإعادة حساب HMAC لمصطلح البحث. HMAC(key, "[email protected]") حتمي النتيجة، لذا يعمل البحث بالمطابقة التامة على أكمل وجه، ولا يسرّب الفهرس شيئًا قابلًا للقراءة، لأنه بدون مفتاح المستأجر لا يمكنك حساب بصمة للمقارنة معها.
لكن لاحظ الغرض من HMAC. هدف تصميمه بالكامل هو أن تنتج المدخلات المتشابهة مخرجات لا علاقة بينها: اقلب بتًا واحدًا فتحصل على بصمة مختلفة تمامًا. لا علاقة بين HMAC("ali") و HMAC("alistair") على الإطلاق. الخاصية التي تجعل الفهرس الأعمى آمنًا عند التسريب هي بالضبط الخاصية التي تجعله عاجزًا عن الإجابة عن سؤال "يبدأ بـ". فالترتيب هو التسريب. لا يكسر الفهرس الأعمى البحث بالبادئة عرضًا، بل يكسره من حيث المبدأ.
وهكذا تدهور مربع البحث إلى المطابقة التامة، بصمت، لأن المطابقة التامة كانت السؤال الوحيد الذي ما زال الفهرس قادرًا على الإجابة عنه.
التصميم: فهرسة كل بادئة كقيمة مستقلة
إذا كان الفهرس لا يجيب إلا عن "يساوي"، فحوّل "يبدأ بـ" إلى "يساوي".
كل بادئة من الجزء المحلي المعياري لعنوان البريد (الجزء الذي يسبق @) تحصل على صف خاص بها في الفهرس الأعمى. بالنسبة إلى [email protected]، هذا يعني صفوفًا لـ al و ali و alis و alist وهكذا: PartitionKey = HMAC(prefix)، و RowKey = معرّف المستخدم. الآن "يبدأ بـ ali" هو بحث بمطابقة تامة على HMAC("ali"): استعلام نقطي واحد، دون حاجة إلى أي ترتيب. وكان بحث الأسماء لدينا يعمل بهذه الطريقة أصلًا للسبب نفسه؛ البريد الإلكتروني انضم إليه فحسب.
ثابتان يحافظان على عقلانية التصميم. تبدأ البادئات عند حرفين (عمليات البحث بحرف واحد لم تكن مفيدة يومًا وتضاعف عدد الصفوف) وتتوقف عند حد أقصى قدره 16 (وهو ما يحدّ من التشعب لكل بريد؛ الاستعلام الأطول يطابق ببساطة أول 16 حرفًا منه، ثم تُرشَّح حفنة المرشحين بعد فك التشفير). وبذلك يكلف كل بريد إلكتروني 15 صفًا فهرسيًا على الأكثر: تُكتب عند الإنشاء، وتُنقل عند تغيير البريد، وتُحذف عند حذف المستخدم.
هذه هي المقايضة في جملة واحدة: تستعيد الترتيب الذي رفضت تسريبه، وتدفع ثمنه تشعبًا في الكتابة. التخزين والكتابة رخيصان؛ أما البنية المسربة فليست كذلك. إنها مقايضة جيدة.
دقيقة واحدة في مسار النقل عند التغيير استحقت تعليقًا خاصًا بها في الكود: صفوف البادئات مفهرسة على الجزء المحلي، لذا يجب أن تنطلق إعادة الكتابة عند تغيّر الجزء المحلي، بمعزل عن النطاق. إعادة تسمية ضمن النطاق نفسه، من [email protected] إلى [email protected]، تبدو لحارس شرطي كُتب وفهرس النطاق في ذهن كاتبه وكأنها "النطاق لم يتغير، تجاوز عمل الفهرسة"، وكانت ستترك صفوف البادئات القديمة تشير إلى المستخدم المعاد تسميته إلى الأبد.
ولنكن صادقين بشأن ما بنيناه: هذا الفهرس يسرّب عمدًا تساوي البادئات؛ فالمهاجم الذي بحوزته الجدول يستطيع أن يرى أن مستخدمَين يتشاركان بادئة بريد من ثلاثة أحرف، وإن لم يعرف ما هي. التشفير القابل للبحث لا يقضي على التسريب أبدًا؛ بل يتيح لك اختياره بوعي بحسب شكل كل استعلام. التساوي وتساوي البادئات هما التسريب الذي اخترناه. وهذا الإطار (اختر تسريبك، ثم هندس كل شيء آخر حوله) هو جوهر هذا التخصص بأكمله.
نجح التصميم. ثم بدأت مشكلات الأنظمة.
الفخ الأول: المفتاح الذي تعذّر إنشاؤه
تحتاج الفهارس العمياء إلى مفتاح HMAC لكل مستأجر، يُجهَّز في محرك transit الخاص بـ Vault مثل مفاتيح التشفير لدينا. إنشاء أحدها أعاد خطأ 500: invalid key size for HMAC key.
يتطلب Vault تحديد key_size صراحةً للمفاتيح من النوع hmac (من 32 إلى 512 بايت)، بينما تمنعه الأنواع ثابتة الحجم (aes256-gcm96 و ecdsa-p256). استدعاء إنشاء المفاتيح لدينا كان يرسل {"type":"hmac"} فقط. حقل واحد مفقود.
وإليك سبب كون هذا فخًا لا مجرد بلاغ عن خلل: كل عملية ترميز (tokenize) كانت ترمي استثناءً، ومسار تسجيل الدخول يقوم بالترميز، لأن العثور على مستخدم عبر بريده الإلكتروني عند تسجيل الدخول يمر عبر الفهرس الأعمى نفسه الذي يستخدمه بحث لوحة الإدارة. تفعيل التشفير لم يكسر البحث. بل كسر تسجيل الدخول. الميزة التي عرضها التسويقي هو "مستخدموك أكثر أمانًا" أسقطت تسجيل الدخول عند أول تفعيل في بيئة التطوير. الحل هو key_size=32 (أي HMAC-SHA256) لمفاتيح hmac، مع حذفه للأنواع ثابتة الحجم، وقاعدة دائمة: اختبر مسار الترميز اختبار دخان مقابل Vault حقيقي قبل تفعيل التشفير في أي مكان. فالمحاكيات (mocks) لا تتحقق من صحة حمولات إنشاء المفاتيح.
الفخ الثاني: التحديث الذي قد يترك المستخدم عالقًا
تغيير البريد الإلكتروني يعني صيانة الفهرس: إزالة الصفوف القديمة وكتابة الجديدة. تنفيذنا الأول فعل ذلك بهذا الترتيب: الحذف ثم الكتابة. طبيعي، مرتب، خاطئ.
كل كتابة لصف جديد باتت تتضمن Vault (لحساب قيم PartitionKey عبر HMAC). احذف الصفوف القديمة أولًا، وأي تعثر في Vault أثناء الكتابة يترك المستخدم بلا صفوف البحث القديمة ولا الجديدة. هو موجود، مشفرًا، في الجدول، ولا شيء يستطيع العثور عليه. بما في ذلك تسجيل الدخول. هذا ليس بحثًا متدهورًا؛ بل مستخدم محروم من الدخول، إلى أن تمر عملية إعادة فهرسة ما في المستقبل.
الحل يكمن في الترتيب لا في معالجة الأخطاء: الكتابة قبل الحذف، في كل موضع تُصان فيه الفهارس. الانهيار بين الخطوتين يترك الآن صفًا قديمًا زائدًا (غير ضار، ويُنظَّف لاحقًا بتراخٍ) بدلًا من صف مفقود. انتقل نمط الفشل من "مستخدم يتعذر الوصول إليه" إلى "صف واحد زائد"، دون أي تكلفة. عندما يتضمن مسار الكتابة اعتمادية بعيدة، اختر ترتيب الخطوات الذي يمكنك التعايش مع حالته نصف المكتملة.
الفخ الثالث: القسم الذي التهم عملية استيراد
نحتفظ أيضًا بفهرس أعمى للنطاقات: HMAC(domain) → الأعضاء، بحيث يكون "كل من في acme.com" عملية بحث واحدة. للتجزئة الحتمية عاقبة لا يحسب أحد حسابها حتى تأتي عملية استيراد: كل مستخدمي النطاق الواحد يهبطون في قسم (partition) واحد. قسم Azure Table يتحمل نحو 2000 عملية في الثانية. استيراد 50 ألف مستخدم من نطاق واحد من Auth0 صبّ كل كتابات فهرس النطاق في عنق الزجاجة هذا بالضبط.
الحل هو التوزيع على دلاء (bucketing): يتوزع الأعضاء على 16 قسمًا بحسب تجزئة معرّف المستخدم، وتتشعب قراءات النطاق عبر الدلاء، وهي محدودة ونادرة بما يكفي كي لا تشكل عبئًا. تفصيلان حملا الدرس. تجزئة الدلو هي FNV-1a مكتوبة يدويًا، لأن string.GetHashCode في .NET غير مستقرة عمدًا بين العمليات: وزّع بها ويحسب معالِج الغد دلوًا مختلفًا للمستخدم نفسه فلا يجد الصف الذي يُفترض أن يحذفه. كما أن مسار القراءة ما زال يمسح الأقسام القديمة غير الموزعة على دلاء، فبقيت الصفوف الموجودة قابلة للعثور عليها دون أي تعبئة رجعية إجبارية: الكتابات الجديدة تتوزع فورًا، والصفوف القديمة تهاجر كلما جرى المساس بالمستخدم في المرة التالية.
الدرس
لا شيء في هذه القصة تشفير مبتكر. عمر HMAC عقود؛ و"جزّئ القيمة، وفهرس التجزئة" عبارة تتسع لها جملة واحدة. كل ما كلفنا فعلًا كان عمل أنظمة: ما أشكال الاستعلامات التي يحتاجها المنتج حقًا (التساوي والبادئة والنطاق، وكل منها حصل على فهرسه الخاص، لأن الفهرس الأعمى يجيب عن سؤال واحد بالضبط)؛ وكيف تُجهَّز المفاتيح وماذا يحدث في مسار تسجيل الدخول حين لا تُجهَّز؛ وبأي ترتيب تجري كتابات الفهرس حين يجلس KMS بعيد في المنتصف؛ وأين تركّز التجزئة الحتمية حملًا لم يعرفه النص الصريح قط.
يُباع التشفير القابل للبحث بوصفه ميزة تشفير. ابنه وستكتشف أنه ميزة أنظمة موزعة ترتدي زي التشفير. مربع البحث يكمل تلقائيًا من جديد (ali تعثر على Alistair بعد ثلاث ضغطات مفاتيح)، ونسخة مسروقة من الجدول نفسه تُظهر بصمات HMAC موزعة على ستة عشر دلوًا، أي: لا شيء. وتحقيق الأمرين معًا كان الهدف دائمًا.