كل مطور يستخدم التشفير كل يوم تقريباً حتى لو لم يكتب سطراً واحداً منه بنفسه، فهو يحفظ كلمات مرور المستخدمين في جدول، ويتحقق من توقيع Webhook قادم من بوابة الدفع، ويولد رمزاً لإعادة تعيين كلمة المرور، ويخزن رقم الهوية مشفراً في عمود، ويضع مفتاح API في مكان ما حتى يقرأه التطبيق. وفي مقال الترميز Base64 وBase32 وBase58 وضعنا القاعدة الأولى: الترميز ليس تشفيراً وليس هاش، ورأيت الفرق بين الثلاثة على المدخل نفسه. وفي مقال OWASP Top 10 للمبتدئين رأيت أن أخطاء التشفير Cryptographic Failures في المرتبة الرابعة A04:2025، وهذا المقال هو تفصيلها للمطور. وفيه سوف ننزل طبقة أعمق، فالسؤال هنا ليس «ما الفرق بين الهاش والتشفير؟» وإنما أي هاش تستخدم ولماذا لا يصلح SHA-256 لكلمات المرور، وما الذي يحدث إذا تكرر ال Nonce في AES-GCM، ولماذا يظهر شكل الصورة من خلال التشفير في وضع ECB، وأين تحفظ المفتاح، وما المكتبة التي تستخدمها في Python وC# وJava وNode.js بدلاً من أن تكتب الخوارزمية بنفسك.
وسوف نناقش في هذا المقال ما يلي:
- أربع أدوات لأربعة أسئلة مختلفة: الهاش Hash وHMAC والتشفير Encryption والتوقيع Signature، ومتى تستخدم كلاً منها.
- دوال الهاش Hash Functions وخصائصها، ولماذا سقط MD5 وSHA-1، وما البديل: SHA-256 وSHA-3 وBLAKE2 وBLAKE3.
- سلامة الرسالة Integrity: لماذا لا يكفي الهاش وحده، وكيف يحل HMAC المشكلة.
- تخزين كلمات المرور Password Storage خطوة بخطوة: من النص الصريح إلى Argon2id بمعاملات OWASP، مع حد 72 بايت في bcrypt، وPBKDF2 عندما تحتاج FIPS.
- التشفير المتماثل Symmetric Encryption مع AES-GCM وChaCha20-Poly1305، ولماذا يعتبر تكرار ال Nonce كارثة، ووضع ECB بصورة نولدها بأنفسنا.
- التشفير غير المتماثل Asymmetric Encryption: RSA مقابل المنحنيات البيضاوية ECC، وأطوال المفاتيح بحسب NIST، والفرق بين التوقيع والتشفير، وتبادل المفاتيح Key Exchange.
- العشوائية Randomness: لماذا لا يصلح
randomوMath.randomلأي شيء أمني، مع تجربة نتوقع فيها القيمة التالية. - إدارة المفاتيح Key Management: KMS وHSM وتشفير الظرف Envelope Encryption والتدوير Rotation.
- التشفير ما بعد الحوسبة الكمية Post-Quantum Cryptography باختصار: FIPS 203 و204 و205 وTLS الهجين.
- لماذا لا تخترع خوارزميتك، والمكتبات المناسبة لكل لغة بتراخيصها، مع مثال كامل في C# وآخر في Java.
أربع أدوات لأربعة أسئلة: الهاش Hash وHMAC والتشفير Encryption والتوقيع Signature
أغلب الأخطاء في هذا الموضوع لا تأتي من خوارزمية ضعيفة، وإنما من أداة صحيحة استخدمت للسؤال الخطأ، فمطور يشفر كلمات المرور وهو يحتاج هاش، وآخر يرسل هاش الرسالة معها وهو يحتاج HMAC، وثالث يظن أن التوقيع يخفي محتوى الرسالة. لذلك قبل أي تفاصيل حدد السؤال الذي تريد الإجابة عنه:
- الهاش Hash: هل تغيرت هذه البيانات؟ دالة باتجاه واحد دون مفتاح، يحسبها أي شخص، وتنفعك فقط إذا وصلك الهاش الصحيح من مكان تثق به.
- رمز التحقق HMAC: هل تغيرت الرسالة، وهل جاءت من أحد يملك السر المشترك Shared Secret؟ هاش ممزوج بمفتاح، فمن لا يملك المفتاح لا يستطيع حسابه.
- التشفير Encryption: من يستطيع قراءة البيانات؟ دالة باتجاهين بمفتاح، والهدف منها السرية Confidentiality، والأنماط الحديثة منه تكشف أي تغيير أيضاً.
- التوقيع الرقمي Digital Signature: من كتب هذه الرسالة، وهل يستطيع أي شخص أن يتحقق من ذلك؟ المفتاح الخاص Private Key يوقع والمفتاح العام Public Key يتحقق، والرسالة نفسها تبقى مقروءة.
الصورة التالية تبين الأدوات الأربع جنباً إلى جنب، والقيم القصيرة فيها مأخوذة من مخرجات التجربة في هذا المقال:

تجهيز التجربة Lab: Container واحد وثلاث مكتبات
كل أمثلة Python في هذا المقال تعمل داخل Container واحد من Docker Image الرسمية python:3.12-slim مع ثلاث مكتبات معروفة، وهي مكتبة cryptography للتشفير والتوقيع، ومكتبة argon2-cffi لخوارزمية Argon2، ومكتبة bcrypt. ولا يحتاج ال Container أي منفذ Port لأنه لا يشغل أي خدمة، والخيار --rm يحذفه عند الخروج:
mkdir crypto-lab && cd crypto-lab
docker run --rm -it --name lab-crypto-python -v "$PWD:/lab" -v "$PWD/out:/out" -w /lab python:3.12-slim bash
# داخل ال Container
pip install --no-cache-dir cryptography argon2-cffi bcrypt
python -c "import sys, ssl, cryptography, bcrypt; from importlib.metadata import version; print('Python', sys.version.split()[0], '| cryptography', cryptography.__version__, '| argon2-cffi', version('argon2-cffi'), '| bcrypt', bcrypt.__version__, '|', ssl.OPENSSL_VERSION)"والمخرج سوف يكون كما يلي:
Python 3.12.15 | cryptography 50.0.2 | argon2-cffi 25.1.0 | bcrypt 5.0.0 | OpenSSL 3.5.7 9 Jun 2026ثم نحفظ كل مثال في ملف داخل المجلد ونشغله بـ python اسم_الملف.py، وفي النهاية نخرج من ال Container بالأمر exit فيحذف تلقائياً. وأمثلة C# وJava وNode.js في القسم الخاص بالمكتبات تعمل بنفس الطريقة في Docker Images الرسمية لكل لغة.
دوال الهاش Hash Functions: البصمة التي لا تعود
دالة الهاش Hash Function تأخذ أي مدخل بأي طول، حرفاً واحداً أو ملفاً من عشرة جيجابايت، وتخرج بصمة Digest بطول ثابت، 32 بايت في SHA-256 مثلاً، وهي مثل بصمة الإصبع للإنسان: صغيرة وثابتة وتدل على صاحبها، لكنك لا تستطيع أن ترسم الإنسان من بصمته. وقد رأيت هذا في مقال البلوكتشين للمبتدئين حيث تحمل كل كتلة هاش ما قبلها، وفي سلسلة الهاش في سجل التدقيق التي تكشف أي تعديل في حدث قديم، وفي Git الذي يسمي كل Commit بهاش محتواه.
خصائص الهاش الآمن Secure Hash Properties
حتى تصلح الدالة للاستخدام الأمني يجب أن تحقق ثلاث خصائص، وكل خاصية منها تحمي استخداماً محدداً:
- مقاومة الرجوع إلى المدخل Preimage Resistance: إذا رأيت الهاش فلا تستطيع أن تجد مدخلاً يعطيه إلا بالتجربة العمياء، وهذا ما يجعل الهاش «باتجاه واحد One-Way».
- مقاومة إيجاد مدخل ثان Second Preimage Resistance: إذا أعطيتك ملفاً وهاشه فلا تستطيع أن تصنع ملفاً آخر له الهاش نفسه، وهذا ما يحمي ملف التحميل الذي تتحقق من هاشه.
- مقاومة التصادم Collision Resistance: لا تستطيع أن تجد أي مدخلين مختلفين لهما الهاش نفسه، حتى لو اخترت المدخلين بنفسك، وهذه أصعب الخصائص وأول ما يسقط عندما تضعف الدالة، وهي التي تحمي التوقيعات والشهادات.
وبالإضافة لذلك فإن تغيير بت واحد في المدخل يغير نصف بتات الناتج تقريباً، وهذا ما يسمى أثر الانهيار Avalanche Effect. والمثال التالي يبين ذلك، ويحسب بصمة الكلمة نفسها بثلاث دوال مختلفة:
import hashlib
for word in ["ArabRoot", "Arabroot"]:
print(f"SHA-256 {word} {hashlib.sha256(word.encode()).hexdigest()}")
print(f"SHA3-256 ArabRoot {hashlib.sha3_256(b'ArabRoot').hexdigest()}")
print(f"BLAKE2b ArabRoot {hashlib.blake2b(b'ArabRoot', digest_size=32).hexdigest()}")
print(f"SHA-256 1 MB of zeros {hashlib.sha256(bytes(1_000_000)).hexdigest()}")والمخرج سوف يكون كما يلي:
SHA-256 ArabRoot 145b7aa7713ff18611d79dcc24dcc734b84a22066fc96d413aa88912487912a7
SHA-256 Arabroot f67bd948bffb297845e39fe5d348ec82b00043ea53c2048d7bf14623d38055e3
SHA3-256 ArabRoot bbd7f73ab3fc9552ba77003ebfaa74d345a176c0fda016a629eb07107cf85460
BLAKE2b ArabRoot 70469b4193815788e4f41917f8013e7f5e0561776edef5c279df07e619004ffe
SHA-256 1 MB of zeros d29751f2649b32ff572b5e0a9f541ea660a50f94ff0beedfb0b692b924cc8025في المخرج أعلاه لاحظ التالي:
- الفرق بين
ArabRootوArabrootحرف واحد صغير، ومع ذلك لا يوجد أي تشابه بين البصمتين، فلا تستطيع أن تستنتج من قرب البصمتين قرب المدخلين. - كل دالة تعطي بصمة مختلفة تماماً للكلمة نفسها، لذلك عندما تخزن هاشاً اكتب معه اسم الدالة، وإلا فلن يعرف أحد بعد سنتين كيف يتحقق منه.
- مليون بايت من الأصفار أعطت 32 بايت كما أعطتها كلمة من ثمانية حروف، فطول البصمة ثابت مهما كان طول المدخل.
لماذا لم يعد MD5 وSHA-1 آمنين؟
الدالتان MD5 وSHA-1 سقطتا في الخاصية الثالثة، أي مقاومة التصادم. ففي 23 فبراير 2017 أعلن فريق من Google ومعهد CWI في أمستردام أول تصادم عملي في SHA-1 باسم SHAttered، ونشروا ملفي PDF مختلفين في المحتوى لهما بصمة SHA-1 نفسها، والتفاصيل في الورقة البحثية. وهذا يعني عملياً أن من يستطيع أن يجعلك توقع على الملف الأول يستطيع أن يقدم توقيعك على الملف الثاني، لأن أغلب أنظمة التوقيع توقع على الهاش وليس على الملف نفسه. وبعد ذلك أعلنت NIST في ديسمبر 2022 إيقاف SHA-1 وطلبت الانتقال إلى SHA-2 وSHA-3، مع موعد نهائي في 31 ديسمبر 2030. أما MD5 فوضعه أسوأ لأن تصادماته أسهل بكثير، ولذلك لا يصلح لأي استخدام أمني على الإطلاق.
وقد يتساءل البعض: هل أحذف MD5 من كل مكان في الكود؟ والإجابة أن المهم هو السؤال الذي تستخدمه للإجابة عنه، فإذا كان الهدف اكتشاف ملف تالف أثناء النسخ بين قرصين، ولا يوجد طرف يحاول أن يخدعك، فإن مجموع التحقق Checksum يكفي. لكن بمجرد أن يصبح السؤال «هل غير أحدهم هذا الملف عمداً؟» أو تدخل البصمة في توقيع أو شهادة أو تخزين كلمة مرور، فإن MD5 وSHA-1 خارج النقاش، ولذلك الأسهل أن تستخدم SHA-256 في كل مكان فلا تحتاج أن تفكر في كل حالة على حدة.
ماذا تستخدم اليوم؟ SHA-256 وSHA-3 وBLAKE2 وBLAKE3
- SHA-256 وSHA-512 من عائلة SHA-2: المعيار FIPS 180-4، موجودة في كل لغة وكل نظام وكل جهاز، وتسرعها المعالجات الحديثة، وهي الاختيار الافتراضي إذا لم يكن لديك سبب لغيرها.
- SHA-3: المعيار FIPS 202، مبنية على تصميم مختلف تماماً اسمه Keccak، فإذا ظهر ضعف في بنية SHA-2 يوماً ما فلن ينتقل إليها، ومن مزاياها بحسب فريق Keccak أنها لا تعاني من هجوم إطالة الرسالة Length Extension الذي نشرحه في القسم التالي.
- BLAKE2 وBLAKE3: دوال سريعة جداً بتصميم حديث، وBLAKE2 معرفة في RFC 7693 وموجودة في مكتبة Python القياسية كما رأيت، وBLAKE3 أسرع منها ويمكن تقسيم حسابه على عدة أنوية، ومكتبته الرسمية في مستودع BLAKE3. وتناسب البصمات الكثيرة مثل فحص ملايين الملفات، لكنها ليست في قائمة FIPS، فإذا كان مشروعك يطلب FIPS فالتزم بـ SHA-2 أو SHA-3.
سلامة الرسالة Integrity: لماذا لا يكفي الهاش، وما هو HMAC؟
الطريقة البسيطة: نرسل الهاش مع الرسالة
لنفرض أن بوابة الدفع ترسل إلى تطبيقك Webhook فيه رقم الطلب والمبلغ، وتريد أن تتأكد أن أحداً في الطريق لم يغير المبلغ. والحل الأول الذي يخطر على البال أن ترسل البوابة مع الرسالة بصمة SHA-256 لها، فيحسب التطبيق البصمة ويقارن. والمشكلة أن المخترق الذي يستطيع تغيير الرسالة يستطيع أيضاً أن يحسب بصمتها الجديدة ويضعها مكان القديمة، فالهاش لا يحتاج أي سر، وبالتالي فالتطبيق سوف يجد بصمة صحيحة لرسالة مزورة.
والحل الثاني أن نضيف سراً مشتركاً قبل الرسالة ثم نحسب الهاش، أي sha256(secret + message)، وهذا يبدو صحيحاً لأن المخترق لا يعرف السر، لكنه يفشل مع SHA-256 وSHA-1 وMD5 بسبب هجوم إطالة الرسالة Length Extension Attack: فبنية هذه الدوال تسمح لمن يرى البصمة وطول الرسالة أن يضيف بيانات في نهايتها ويحسب البصمة الجديدة دون أن يعرف السر، وهذا ما يشير إليه فريق Keccak عندما يذكر أن SHA-1 وSHA-2 تحتاجان بنية HMAC المتداخلة لهذا السبب.
الطريقة الصحيحة: HMAC والمقارنة في زمن ثابت
الحل الأنسب هو رمز التحقق من الرسالة المبني على الهاش Hash-based Message Authentication Code واختصاراً HMAC، المعرف في RFC 2104، وهو يمزج المفتاح بالرسالة على مرحلتين بطريقة تمنع هجوم الإطالة، فلا يستطيع أحد أن يحسب الرمز الصحيح لرسالة جديدة دون المفتاح. وهذا بالضبط ما يفعله توقيع HS256 في JWT كما شرحنا في مقال الجلسات وJWT، وما تستخدمه أغلب منصات الدفع والكود لتوقيع ال Webhooks. والمثال التالي يبين الفرق:
import hmac, hashlib, secrets
key = secrets.token_bytes(32)
msg = b'{"order": 1042, "amount": 150}'
tag = hmac.new(key, msg, hashlib.sha256).hexdigest()
print("message :", msg.decode())
print("SHA-256 :", hashlib.sha256(msg).hexdigest())
print("HMAC-SHA256 :", tag)
forged = b'{"order": 1042, "amount": 1}'
print("attacker recomputes SHA-256 of forged message:", hashlib.sha256(forged).hexdigest())
print("server checks HMAC of original:", hmac.compare_digest(tag, hmac.new(key, msg, hashlib.sha256).hexdigest()))
print("server checks HMAC of forged :", hmac.compare_digest(tag, hmac.new(key, forged, hashlib.sha256).hexdigest()))والمخرج سوف يكون كما يلي:
message : {"order": 1042, "amount": 150}
SHA-256 : 9d760e8cc93e419779f43dd35e65c0b0308400ee6da7e6d8ee983efdef00861d
HMAC-SHA256 : 368a92e49b683d33300fbaac02d8aead246fbc782de4f9801adfb73736d28925
attacker recomputes SHA-256 of forged message: 95178f4e4f1627145a62982619fb5e39b2a84cf8bc3c00e7e9adbc2262889486
server checks HMAC of original: True
server checks HMAC of forged : Falseفي الإعداد أعلاه لاحظ التالي:
- المخترق غير المبلغ إلى 1 وحسب SHA-256 الجديد بسهولة، ولو كان التطبيق يتحقق بالهاش وحده لقبل الرسالة المزورة، أما HMAC للرسالة المزورة فلم يطابق لأن المخترق لا يملك المفتاح.
- المقارنة تمت بالدالة
hmac.compare_digestوليس بـ==، والسبب أن المقارنة العادية تتوقف عند أول بايت مختلف، فيختلف زمنها قليلاً بحسب عدد البايتات الصحيحة، ومن يقيس هذا الفرق مرات كثيرة يستطيع أن يخمن الرمز بايتاً بعد بايت، وهذا هجوم التوقيت Timing Attack. والمقابل في .NET هوCryptographicOperations.FixedTimeEquals، وفي JavaMessageDigest.isEqual، وفي Node.jscrypto.timingSafeEqual. - المفتاح ولد بـ
secrets.token_bytes(32)، أي 32 بايت عشوائية حقيقية، وليس كلمة يكتبها إنسان.
تخزين كلمات المرور Password Storage: من النص الصريح إلى Argon2id
هذا هو المثال الذي يجمع أغلب ما سبق، وفيه سوف نمشي مع الحلول التي تخطر على البال واحداً بعد الآخر، ونكسر كل واحد منها قبل أن نصل إلى الحل الصحيح. والهدف هنا واضح: إذا سرق أحدهم جدول المستخدمين كاملاً، عبر SQL Injection أو نسخة احتياطية منسية، فيجب أن يكون الوصول إلى كلمات المرور نفسها مكلفاً جداً.
وقبل أن نبدأ تذكر أن أفضل كلمة مرور هي التي لا تحتاج أن تخزنها أصلاً، وهذا ما شرحناه في الدخول دون كلمة مرور Passkeys وFIDO2، لكن أغلب التطبيقات ما زالت تحتاج كلمات المرور اليوم ولو كخيار احتياطي.
النص الصريح Plain Text، ثم التشفير
الطريقة الأولى أن تحفظ كلمة المرور كما هي، وهي أسوأ الطرق على الإطلاق، فكل من يرى الجدول يرى كلمات المرور، ومنهم موظف الدعم ومن يقرأ النسخة الاحتياطية، وبما أن أغلب الناس يكررون كلمة المرور نفسها في مواقع كثيرة فإن التسريب لا يتوقف عند تطبيقك. والطريقة الثانية أن تشفر كلمة المرور بمفتاح، وهي تبدو أفضل لكنها تفشل لسبب بسيط: التشفير باتجاهين، فالتطبيق يملك المفتاح ليتحقق، وبالتالي فكل من يصل إلى السيرفر يصل إلى المفتاح غالباً، ويفك كل الكلمات دفعة واحدة. والتطبيق في الحقيقة لا يحتاج أن يعرف كلمة المرور أبداً، وإنما يحتاج فقط أن يتحقق أن ما كتبه المستخدم الآن هو نفس ما كتبه عند التسجيل.
الهاش السريع Fast Hash: لماذا يفشل SHA-256 هنا؟
الطريقة الثالثة أن تحفظ SHA-256(password)، وعند الدخول تحسب البصمة وتقارن، وهذه خطوة صحيحة في الاتجاه لأن الهاش لا يعود. لكن المخترق لا يحتاج أن يعكس الهاش، فهو يأخذ قائمة بملايين الكلمات المسربة سابقاً ويحسب بصمة كل واحدة ويقارن، والسؤال الوحيد هو كم كلمة يستطيع أن يجرب في الثانية. والمثال التالي يقيس ذلك على نواة واحدة داخل ال Container، والأرقام توضيحية Illustrative على جهازنا فقط، والمهم فيها هو النسبة بين الأسطر وليس الرقم نفسه:
import hashlib, time
from argon2 import PasswordHasher
def per_second(name, fn, seconds=1.0):
n, start = 0, time.perf_counter()
while time.perf_counter() - start < seconds:
fn(b"S3cret!%d" % n); n += 1
print(f"{name:<10} {n / seconds:>12,.0f} hashes/second")
per_second("MD5", lambda p: hashlib.md5(p).digest())
per_second("SHA-1", lambda p: hashlib.sha1(p).digest())
per_second("SHA-256", lambda p: hashlib.sha256(p).digest())
ph = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1) # OWASP minimum
start = time.perf_counter()
for i in range(20):
ph.hash(f"S3cret!{i}")
ms = (time.perf_counter() - start) / 20 * 1000
print(f"{'Argon2id':<10} {ms:>9.1f} ms per hash -> {1000 / ms:,.0f} hashes/second")والمخرج سوف يكون كما يلي:
MD5 1,174,887 hashes/second
SHA-1 1,110,383 hashes/second
SHA-256 604,521 hashes/second
Argon2id 32.5 ms per hash -> 31 hashes/secondنواة واحدة في Python جربت أكثر من 600 ألف كلمة في الثانية مع SHA-256، وأكثر من مليون مع MD5، والمخترق الحقيقي لا يستخدم Python ولا نواة واحدة، وإنما بطاقات رسوميات GPU مصممة لهذا النوع من الحساب المتوازي، فالأرقام عنده أكبر بكثير. أما Argon2id بالمعاملات التي توصي بها OWASP فاحتاج 32 مللي ثانية للكلمة الواحدة، أي نحو 31 محاولة في الثانية على النواة نفسها، وهذا لا يزعج المستخدم الذي يدخل مرة واحدة، لكنه يغير حسابات المخترق الذي يريد أن يجرب مليار كلمة.
الملح Salt والفلفل Pepper
وهناك مشكلة ثانية في الهاش السريع غير السرعة، والمثال التالي يبينها مع مستخدمين اختارا كلمة المرور نفسها:
import hashlib
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
users = {"sara": "S3cret!", "omar": "S3cret!"}
print("1) plain SHA-256, no salt")
for u, p in users.items():
print(f" {u:<5} {hashlib.sha256(p.encode()).hexdigest()}")
ph = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
print("2) Argon2id with a random salt per user")
stored = {}
for u, p in users.items():
stored[u] = ph.hash(p)
print(f" {u:<5} {stored[u]}")
print("3) login")
print(" sara + S3cret! ->", ph.verify(stored["sara"], "S3cret!"))
try:
ph.verify(stored["sara"], "s3cret!")
except VerifyMismatchError as e:
print(" sara + s3cret! ->", type(e).__name__)
print(" needs rehash with stronger params?", PasswordHasher(time_cost=3, memory_cost=19456, parallelism=1).check_needs_rehash(stored["sara"]))والمخرج سوف يكون كما يلي:
1) plain SHA-256, no salt
sara 88d3ff09991bae39cb844746c78ab09d4c62050857d336751e37c4b1d84a1d44
omar 88d3ff09991bae39cb844746c78ab09d4c62050857d336751e37c4b1d84a1d44
2) Argon2id with a random salt per user
sara $argon2id$v=19$m=19456,t=2,p=1$YZap5FSHy4LI1VtL6wYXlg$0BIc8IKbR9X55h5GRvVxxiDA9BKSjian28Nmj6Bq08I
omar $argon2id$v=19$m=19456,t=2,p=1$Aykik+FPWNMjTbv3FDgDlg$MbfPNIR4MoV0oG7f3yzSuIlnX/lbWgAsXESKXfnnxWI
3) login
sara + S3cret! -> True
sara + s3cret! -> VerifyMismatchError
needs rehash with stronger params? Trueفي المخرج أعلاه لاحظ التالي:
- مع SHA-256 دون Salt حصلت سارة وعمر على البصمة نفسها تماماً، بل هي نفس البصمة التي رأيتها في مقال الترميز للكلمة
S3cret!، أي أن أي شخص في العالم يحسب هذه البصمة يحصل على الرقم نفسه. وهذا يعني أن المخترق يحسب بصمات القائمة مرة واحدة فقط ويقارنها بكل المستخدمين معاً، أو يستخدم جداول جاهزة محسوبة مسبقاً Rainbow Tables، ويعرف فوراً من يشترك في كلمة واحدة. - مع Argon2id اختلف الناتج تماماً للكلمة نفسها، والسبب الملح Salt، وهو قيمة عشوائية من 16 بايت تولد لكل مستخدم عند التسجيل وتدخل في الحساب. ولاحظ أن الملح ليس سراً، فهو مكتوب في النص المخزن نفسه بعد
$m=19456,t=2,p=1$، ووظيفته أن يجعل كل مستخدم حالة مستقلة يحتاج المخترق أن يهاجمها وحدها، وأن يلغي الجداول المحسوبة مسبقاً. - النص المخزن يحمل كل ما يلزم للتحقق: اسم الخوارزمية والإصدار والمعاملات والملح والبصمة، لذلك لا تحتاج أعمدة منفصلة، ولا تحتاج أن تتذكر في الكود المعاملات التي استخدمتها قبل سنتين.
- الدالة
check_needs_rehashأعادتTrueعندما قارنا النص المخزن بمعاملات أقوى، وهذا ما تستخدمه لرفع المعاملات تدريجياً: عند كل دخول ناجح تتحقق، فإذا كانت المعاملات قديمة تحسب الهاش من جديد بالمعاملات الحالية وتحفظه، لأن كلمة المرور الصريحة بين يديك في هذه اللحظة فقط.
أما الفلفل Pepper فهو سر واحد مشترك لكل كلمات المرور، يحفظ خارج قاعدة البيانات في خزنة أسرار أو HSM، ويضاف إلى الحساب، فإذا سرق المخترق قاعدة البيانات وحدها، عبر SQL Injection مثلاً، فلن يستطيع أن يجرب أي كلمة لأنه لا يعرف الفلفل. وتصف ورقة OWASP لتخزين كلمات المرور طريقتين له، إما قبل الهاش أو بعده بـ HMAC، مع تنبيه مهم: تغيير الفلفل يحتاج كلمات المرور الصريحة، فإذا تسرب فلا بد أن يعيد كل المستخدمين تعيين كلماتهم. لذلك الفلفل طبقة إضافية مفيدة وليس بديلاً عن الملح والخوارزمية البطيئة.
Argon2id ومعاملاته Parameters بحسب OWASP وRFC 9106
الحل الأنسب اليوم هو دالة صممت لكلمات المرور تحديداً، أي دالة بطيئة عمداً وقابلة للضبط، وتحتاج ذاكرة كبيرة في كل محاولة Memory-Hard حتى لا تستفيد بطاقات GPU من آلاف أنويتها الصغيرة التي تملك ذاكرة قليلة لكل نواة. وهذه الدالة هي Argon2 التي فازت بمسابقة Password Hashing Competition في 2015، ونسختها الموصى بها Argon2id، وهي معرفة في RFC 9106. ولها ثلاثة معاملات: الذاكرة m بالكيلوبايت KiB، وعدد المرور على الذاكرة t، ودرجة التوازي p.
وتوصي ورقة OWASP باستخدام Argon2id بحد أدنى 19 ميجابايت من الذاكرة ومرورين ودرجة توازي واحدة، أي m=19456, t=2, p=1، وتذكر خيارات متكافئة تبادل الذاكرة بعدد المرور، مثل m=47104 (46 MiB), t=1, p=1 وm=12288 (12 MiB), t=3, p=1 وm=9216 (9 MiB), t=4, p=1 وm=7168 (7 MiB), t=5, p=1. أما RFC 9106 نفسه فخياره الأول الموصى به أقوى بكثير، وهو t=1 وp=4 مع 2 جيجابايت من الذاكرة، وخياره الثاني لمن لديه ذاكرة أقل هو t=3 وp=4 مع 64 ميجابايت، وكلاهما بملح 128 بت وبصمة 256 بت.
وقد يتساءل البعض: أي الرقمين أختار؟ والإجابة أن معاملات OWASP حد أدنى لسيرفر ويب عادي يستقبل دخولاً متزامناً من مستخدمين كثيرين، فكل محاولة دخول تحجز 19 ميجابايت، ومئة محاولة في الوقت نفسه تحجز نحو 2 جيجابايت. لذلك ابدأ بمعاملات OWASP، ثم قس على سيرفرك الحقيقي، وارفع الذاكرة ما دام زمن الدخول مقبولاً والسيرفر يتحمل الحمل، وتنصح OWASP بأن يبقى حساب الهاش أقل من ثانية. ولا تنس أن تحمي صفحة الدخول بتحديد عدد المحاولات Rate Limiting، لأن الدالة البطيئة نفسها قد تستخدم لإنهاك السيرفر.
الصورة التالية تبين المسار كاملاً عند التسجيل وعند الدخول، والقيم فيها من مخرجات التجربة:

bcrypt وحد 72 بايت
إذا كان نظامك يستخدم bcrypt فهو ليس كارثة، فـ OWASP تعتبره مقبولاً للأنظمة القديمة بعامل عمل Work Factor لا يقل عن 10، لكنه يحمل قيداً يجب أن تعرفه: أغلب تطبيقاته تقرأ أول 72 بايت فقط من كلمة المرور. والمثال التالي يبين كيف تتعامل المكتبة الحديثة مع هذا القيد:
import bcrypt
base = "a" * 72
h = bcrypt.hashpw(base.encode(), bcrypt.gensalt(rounds=12))
print("bcrypt version:", bcrypt.__version__)
print("hash:", h.decode())
print("72 bytes, same password ->", bcrypt.checkpw(base.encode(), h))
try:
print("73 bytes, extra 'X' at end ->", bcrypt.checkpw((base + "X").encode(), h))
except ValueError as e:
print("73 bytes, extra 'X' at end -> ValueError:", e)
arabic = "كلمة" * 9 # 36 Arabic letters, 2 bytes each in UTF-8
print("36 Arabic letters =", len(arabic.encode()), "bytes")والمخرج سوف يكون كما يلي:
bcrypt version: 5.0.0
hash: $2b$12$2BISf3aAa3OHs3vaw6whjO8a/ybMhRBS4oVVTzhyE/rf6/rwukX0G
72 bytes, same password -> True
73 bytes, extra 'X' at end -> ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
36 Arabic letters = 72 bytesفي المخرج أعلاه لاحظ التالي:
- الإصدار 5.0.0 من مكتبة bcrypt في Python يرفض صراحة أي كلمة أطول من 72 بايت بخطأ
ValueError، وهذا سلوك جيد لأنه يجبرك على أن تقرر بنفسك. لكن مكتبات كثيرة في لغات أخرى، والإصدارات القديمة من هذه المكتبة نفسها، تقطع الكلمة بصمت، فتصبح كل كلمة تبدأ بنفس 72 بايت صحيحة. - الحد بالبايت وليس بالحرف، والحرف العربي في UTF-8 بايتان كما شرحنا في مقال الترميز، فكلمة مرور عربية من 36 حرفاً فقط تصل إلى الحد.
- إذا اضطررت لتمرير كلمات أطول فلا تقم بتمرير SHA-256 الخام لها إلى bcrypt، لأن البايت الصفري في الناتج يقطع الكلمة في بعض التطبيقات، وتصف ورقة OWASP الطريقة الآمنة بـ HMAC ثم Base64 قبل bcrypt، والحل الأبسط أن تنتقل إلى Argon2id.
PBKDF2 عندما تحتاج FIPS
إذا كان مشروعك يطلب الالتزام بمعيار FIPS 140، وهو شائع في الجهات الحكومية والقطاع المالي، فإن Argon2 ليست في قائمة الخوارزميات المعتمدة، والخيار هنا PBKDF2، وتوصي OWASP بـ 600,000 تكرار Iterations مع HMAC-SHA-256، أو 220,000 مع HMAC-SHA-512. وPBKDF2 ليس دالة تحتاج ذاكرة كبيرة، لذلك يستفيد المخترق من بطاقات GPU معه أكثر من Argon2id، فهو الخيار الأنسب عندما يفرضه المعيار فقط. وفي .NET تجد الدالة الثابتة Rfc2898DeriveBytes.Pbkdf2، وسوف ترى في مثال C# أنها احتاجت نحو 337 مللي ثانية لـ 600,000 تكرار في تجربتنا.
التشفير المتماثل Symmetric Encryption: AES-GCM وChaCha20-Poly1305
ننتقل الآن من البيانات التي لا تحتاج أن ترجع إليها إلى البيانات التي تحتاج أن تقرأها لاحقاً، مثل رقم الهوية في عمود أو ملف يرفعه العميل أو نسخة احتياطية. والتشفير المتماثل يستخدم مفتاحاً واحداً للتشفير وفك التشفير، وهو سريع ويصلح لأي حجم من البيانات، والسؤال الذي يغفل عنه كثيرون: هل يكفي أن تكون البيانات مخفية؟
التشفير الموثق Authenticated Encryption واختصاراً AEAD
التشفير القديم مثل AES في وضع CBC يخفي البيانات فقط، ولا يكشف إذا غير أحدهم بتاً في النص المشفر، فيفك التطبيق التشفير ويحصل على بيانات مختلفة دون أن يعلم، بل إن رسائل الخطأ عند فك تشفير نص معدل استخدمت تاريخياً لاستخراج النص الأصلي كاملاً فيما يسمى هجوم Padding Oracle. لذلك الحل الأنسب اليوم هو التشفير الموثق مع بيانات مرتبطة Authenticated Encryption with Associated Data واختصاراً AEAD، الذي يخفي البيانات ويضيف وسماً Tag يكشف أي تغيير فيها، وأشهر نوعين منه:
- AES-GCM: المعرف في NIST SP 800-38D، وهو الأكثر انتشاراً، وأغلب المعالجات الحديثة تملك تعليمات خاصة تسرعه.
- ChaCha20-Poly1305: المعرف في RFC 8439، ويعمل بسرعة جيدة حتى على الأجهزة التي لا تملك تسريعاً لـ AES، ونسخته XChaCha20-Poly1305 في libsodium تستخدم Nonce بطول 192 بت يمكن توليده عشوائياً دون قلق من التكرار.
الصورة التالية تبين مدخلات AES-GCM ومخرجاته، وما يحدث عند فك التشفير:

تجربة AES-GCM: التشفير ثم العبث بالنص
المثال التالي يشفر رقم هوية مع ربطه بمعرف المستخدم كبيانات مرتبطة Associated Data، ثم يحاول فك التشفير بعد ثلاثة أنواع من العبث:
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
key = AESGCM.generate_key(bit_length=256)
aes = AESGCM(key)
nonce = os.urandom(12) # 96-bit nonce, new for every message
aad = b"user_id=42" # authenticated, not encrypted
plain = "رقم الهوية 1234567890".encode()
ct = aes.encrypt(nonce, plain, aad)
print("plaintext :", len(plain), "bytes")
print("ciphertext+tag :", len(ct), "bytes (the last 16 are the tag)")
print("nonce (hex) :", nonce.hex())
print("ciphertext (hex):", ct.hex())
print("decrypt :", aes.decrypt(nonce, ct, aad).decode())
tampered = bytearray(ct); tampered[0] ^= 0x01 # flip one bit
cases = [("one bit flipped", aes, bytes(tampered), aad),
("other user_id ", aes, ct, b"user_id=43"),
("wrong key ", AESGCM(AESGCM.generate_key(bit_length=256)), ct, aad)]
for label, cipher, data, ad in cases:
try:
cipher.decrypt(nonce, data, ad)
print(label, "-> decrypted?!")
except InvalidTag:
print(label, "-> InvalidTag")والمخرج سوف يكون كما يلي:
plaintext : 30 bytes
ciphertext+tag : 46 bytes (the last 16 are the tag)
nonce (hex) : 9a203ceb34200be8d2a0d458
ciphertext (hex): 1570a9d603ac2b4fa23b261051acabb4e3aad50c8c284fb98783c0fa96d964489f7b02c58d601fe2b77d0d5915a4
decrypt : رقم الهوية 1234567890
one bit flipped -> InvalidTag
other user_id -> InvalidTag
wrong key -> InvalidTagفي الإعداد أعلاه لاحظ التالي:
- النص الصريح 30 بايت لأن الحروف العربية بايتان، والناتج 46 بايت، أي النص المشفر بالطول نفسه ثم 16 بايت للوسم في النهاية، ولا يوجد حشو Padding كما في CBC.
- قلب بت واحد في النص المشفر جعل فك التشفير يفشل بخطأ
InvalidTag، ولم يرجع أي جزء من النص، فالتطبيق لا يرى بيانات معدلة أبداً. - البيانات المرتبطة
user_id=42لم تشفر لكنها دخلت في الوسم، فعندما حاول أحدهم فك تشفير السجل نفسه باسم المستخدم 43 فشل، وهذا ما يمنع من ينسخ العمود المشفر من صف مستخدم إلى صف مستخدم آخر. - ال Nonce ولد بـ
os.urandom(12)لكل رسالة، وطوله 12 بايت أي 96 بت، وهو ليس سراً ويخزن بجانب النص المشفر لأنك تحتاجه عند فك التشفير.
ال Nonce رقم لا يتكرر أبداً، فماذا يحدث إذا تكرر؟
كلمة Nonce اختصار لـ «Number used once»، أي رقم يستخدم مرة واحدة مع المفتاح نفسه، والسبب أن AES-GCM يولد من المفتاح وال Nonce سلسلة بايتات عشوائية الشكل Keystream ثم يمزجها مع النص بعملية XOR، فإذا تكرر المفتاح وال Nonce تكررت السلسلة نفسها. والمثال التالي يبين النتيجة على رسالتي تحويل مالي من صنعنا:
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = AESGCM.generate_key(bit_length=256)
aes = AESGCM(key)
nonce = os.urandom(12) # the bug: one nonce reused for two messages
p1 = b"TRANSFER 0500 SAR TO ACCT 11111"
p2 = b"TRANSFER 9900 SAR TO ACCT 66666"
c1 = aes.encrypt(nonce, p1, None)[:-16] # drop the tag, keep the ciphertext
c2 = aes.encrypt(nonce, p2, None)[:-16]
xor = lambda a, b: bytes(x ^ y for x, y in zip(a, b))
print("c1 xor c2 :", xor(c1, c2).hex())
print("p1 xor p2 :", xor(p1, p2).hex())
print("equal :", xor(c1, c2) == xor(p1, p2))
# the attacker knows p1 (a fixed template or his own transfer) and never sees the key
print("recovered p2:", xor(xor(c1, c2), p1).decode())والمخرج سوف يكون كما يلي:
c1 xor c2 : 000000000000000000090c0000000000000000000000000000000707070707
p1 xor p2 : 000000000000000000090c0000000000000000000000000000000707070707
equal : True
recovered p2: TRANSFER 9900 SAR TO ACCT 66666المخترق لم يعرف المفتاح، ومع ذلك فإن XOR النصين المشفرين ساوى XOR النصين الصريحين بالضبط، فالسلسلة السرية ألغت نفسها، ولاحظ أن الأصفار في الناتج هي الأماكن التي تتطابق فيها الرسالتان، فمجرد النظر إليه يكشف أين تختلف الرسالتان. وبما أن الرسالة الأولى معروفة، لأنها قالب ثابت أو تحويل قام به المخترق نفسه، فقد استخرج الرسالة الثانية كاملة. والأسوأ أن تكرار ال Nonce في GCM تحديداً يكشف أيضاً مفتاح التحقق الداخلي Hash Subkey الذي يحسب به الوسم كما يشرح الملحق A في NIST SP 800-38D، فيستطيع المخترق بعدها أن يزور رسائل يقبلها التطبيق، لذلك فهو ليس تسريباً لرسالة واحدة وإنما انهيار للنظام مع هذا المفتاح.
وكيف تضمن ألا يتكرر؟ هناك ثلاث طرق عملية:
- Nonce عشوائي من 96 بت مع حد للرسائل: وهو ما فعلناه، لكن احتمال التكرار يزيد مع عدد الرسائل، لذلك يشترط NIST SP 800-38D في القسم 8.3 ألا يتجاوز عدد مرات التشفير بالمفتاح نفسه 2 أس 32 عندما يكون ال Nonce عشوائياً، أي نحو أربعة مليارات رسالة، ثم يجب تغيير المفتاح.
- عداد Counter: رقم يزيد مع كل رسالة، وهو مضمون عدم التكرار ما دام محفوظاً بشكل صحيح، لكنه يصبح خطيراً عندما تعمل عدة نسخ من التطبيق بالمفتاح نفسه أو تستعاد نسخة احتياطية قديمة من العداد.
- خوارزمية تتحمل الخطأ: XChaCha20-Poly1305 بـ Nonce من 192 بت يكفي توليده عشوائياً، أو AES-GCM-SIV المعرف في RFC 8452 الذي لا ينهار عند التكرار، فكل ما يخسره أن الرسالتين المتطابقتين تعطيان نصاً مشفراً متطابقاً.
وضع ECB: لماذا يظهر الشكل من خلال التشفير؟
AES نفسه يشفر كتلة Block واحدة من 16 بايت فقط، ووضع التشغيل Mode of Operation هو الذي يحدد كيف تشفر رسالة أطول. وأبسط الأوضاع اسمه Electronic Codebook واختصاراً ECB، وهو يشفر كل كتلة وحدها بالمفتاح نفسه، ويبدو منطقياً للوهلة الأولى، والمشكلة أن الكتلتين المتطابقتين في النص تعطيان كتلتين متطابقتين في الناتج. ولنرى ذلك بأعيننا نرسم صورة بسيطة فيها قفل ومفتاح وخطوط بألوان مسطحة، ثم نشفر بكسلاتها الخام بـ ECB مرة وبـ GCM مرة بالمفتاح نفسه:
# Draws our own pattern (a padlock and a key on stripes), then encrypts the raw pixels with AES-ECB and AES-GCM.
import os, zlib, struct
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
W, H = 480, 320 # 480 * 3 = 1440 bytes per row, a multiple of the 16-byte AES block
ORANGE, DARK, WHITE, GREEN = (233, 84, 32), (44, 0, 30), (255, 255, 255), (31, 138, 85)
def pixel(x, y):
# padlock body
if 90 <= x < 230 and 140 <= y < 260: return ORANGE
# padlock shackle (a thick ring, upper half)
dx, dy = x - 160, y - 140
if y < 140 and 45**2 <= dx*dx + dy*dy < 70**2: return DARK
# key: ring and blade
dx, dy = x - 320, y - 120
if 25**2 <= dx*dx + dy*dy < 45**2: return GREEN
if 300 <= x < 340 and 165 <= y < 280: return GREEN
if 340 <= x < 380 and (210 <= y < 228 or 248 <= y < 266): return GREEN
# stripes at the bottom
if y >= 290: return DARK if (x // 40) % 2 == 0 else WHITE
return WHITE
raw = bytes(c for y in range(H) for x in range(W) for c in pixel(x, y))
def png(path, data):
rows = b"".join(b"\x00" + data[y*W*3:(y+1)*W*3] for y in range(H))
chunk = lambda t, d: struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d))
with open(path, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", W, H, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(rows, 9)) + chunk(b"IEND", b""))
key = os.urandom(32)
enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
ecb = enc.update(raw) + enc.finalize()
gcm = AESGCM(key).encrypt(os.urandom(12), raw, None)[:len(raw)]
blocks = [raw[i:i+16] for i in range(0, len(raw), 16)]
eblocks = [ecb[i:i+16] for i in range(0, len(ecb), 16)]
print("pixel bytes :", len(raw), "=", len(blocks), "AES blocks")
print("distinct plain blocks:", len(set(blocks)))
print("distinct ECB blocks :", len(set(eblocks)))
print("distinct GCM blocks :", len(set(gcm[i:i+16] for i in range(0, len(gcm), 16))))
for name, data in [("original", raw), ("ecb", ecb), ("gcm", gcm)]:
png(f"/out/ecb-{name}.png", data)
print("written: /out/ecb-original.png /out/ecb-ecb.png /out/ecb-gcm.png")والمخرج سوف يكون كما يلي:
pixel bytes : 460800 = 28800 AES blocks
distinct plain blocks: 72
distinct ECB blocks : 72
distinct GCM blocks : 28800
written: /out/ecb-original.png /out/ecb-ecb.png /out/ecb-gcm.pngالصورة الأصلية فيها 28,800 كتلة لكن 72 كتلة مختلفة فقط، لأن المساحات الملونة تتكرر، ومع ECB بقي العدد 72 بالضبط، أي أن كل كتلة بيضاء أصبحت الكتلة المشفرة نفسها في كل مكان، أما مع GCM فأصبحت كل الكتل مختلفة. والصورة التالية هي الملفات الثلاثة التي كتبها المثال كما هي:

وهذه هي الفكرة نفسها التي اشتهرت بصورة البطريق في مراجع التشفير، وقد رسمنا صورتنا بأنفسنا حتى نعرف كل بكسل فيها. والدرس أن ECB لا يصلح لتشفير أي بيانات أطول من كتلة واحدة. والمشكلة أنه قد يصلك دون أن تطلبه، ففي Java إذا كتبت اسم الخوارزمية وحده دون الوضع اختار المزود الوضع الافتراضي، والمثال التالي يشفر 32 بايت من الأصفار بـ Cipher.getInstance("AES"):
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import java.util.HexFormat;
public class EcbCheck {
public static void main(String[] a) throws Exception {
Cipher c = Cipher.getInstance("AES");
c.init(Cipher.ENCRYPT_MODE, KeyGenerator.getInstance("AES").generateKey());
byte[] out = c.doFinal(new byte[32]);
String h = HexFormat.of().formatHex(out);
System.out.println("provider " + c.getProvider().getName() + ", output " + out.length + " bytes");
System.out.println("block1 " + h.substring(0, 32));
System.out.println("block2 " + h.substring(32, 64));
System.out.println("iv " + (c.getIV() == null ? "none" : "present"));
}
}docker run --rm --name lab-crypto-java-ecb -v "$PWD:/lab" eclipse-temurin:21-jdk-alpine java /lab/EcbCheck.javaوالمخرج سوف يكون كما يلي:
provider SunJCE, output 48 bytes
block1 ba8208e3ce150b5969a382d362d0b9ac
block2 ba8208e3ce150b5969a382d362d0b9ac
iv noneالكتلتان المشفرتان متطابقتان تماماً، ولا يوجد IV، والناتج 48 بايت لأن المزود أضاف كتلة حشو Padding، أي أن مزود SunJCE أعطانا ECB مع حشو PKCS5 دون أي تحذير. لذلك إذا رأيت Cipher.getInstance("AES") أو AES/ECB في كود Java فاعتبره خطأ أمنياً يجب إصلاحه.
التشفير غير المتماثل Asymmetric: RSA والمنحنيات البيضاوية ECC
المشكلة التي تبقى مع التشفير المتماثل هي المفتاح نفسه: كيف يصل إلى الطرف الآخر دون أن يراه أحد؟ والتشفير غير المتماثل يحلها بزوج من المفاتيح، مفتاح عام Public Key تنشره لأي شخص، ومفتاح خاص Private Key لا يغادر جهازك أبداً، وما يفعله أحدهما لا يعكسه إلا الآخر. وقد شرحنا الفكرة الأساسية مع الشهادات في مقال الشهادات الرقمية، وهنا سوف نركز على ما يحتاجه المطور: أي خوارزمية، وبأي طول، ولأي غرض.
RSA أم ECC؟ وأطوال المفاتيح Key Sizes بحسب NIST
هناك عائلتان رئيسيتان: RSA التي تعتمد على صعوبة تحليل عدد كبير إلى عوامله الأولية، والمنحنيات البيضاوية Elliptic Curve Cryptography واختصاراً ECC التي تعطي الأمان نفسه بمفاتيح أصغر بكثير. ويقيس المعيار NIST SP 800-57 الجزء الأول قوة كل خوارزمية بوحدة اسمها قوة الأمان Security Strength بالبت، والجدول التالي من الجدول 2 فيه:
| قوة الأمان Security Strength | المتماثل Symmetric | RSA (طول المعامل) | ECC (طول المنحنى) |
|---|---|---|---|
| 112 بت | 3TDEA (متوقف) | 2048 | 224 إلى 255 |
| 128 بت | AES-128 | 3072 | 256 إلى 383 |
| 192 بت | AES-192 | 7680 | 384 إلى 511 |
| 256 بت | AES-256 | 15360 | 512 فأكثر |
وبحسب الجدول 4 في المعيار نفسه فإن قوة 112 بت مقبولة لحماية البيانات حتى نهاية 2030، ومن 2031 يجب أن تكون 128 بت على الأقل لأي حماية جديدة، أي أن RSA-2048 الشائع اليوم على حدود عمره، وأن RSA-3072 أو منحنى بطول 256 بت هو الحد الأدنى لأي نظام جديد. ولاحظ الفرق في الحجم: منحنى بطول 256 بت يعطي ما يعطيه RSA بطول 3072 بت، والمثال التالي يقارن الأحجام الفعلية:
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.exceptions import InvalidSignature
priv = Ed25519PrivateKey.generate()
pub = priv.public_key()
msg = b"release v2.4.1 sha256=9f2c41e1"
sig = priv.sign(msg)
raw = pub.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
print("Ed25519 public key:", len(raw), "bytes, signature:", len(sig), "bytes")
pub.verify(sig, msg); print("original message -> valid")
try:
pub.verify(sig, b"release v2.4.2 sha256=9f2c41e1")
except InvalidSignature:
print("modified message -> InvalidSignature")
rsa_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
pss = padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH)
rsa_sig = rsa_key.sign(msg, pss, hashes.SHA256())
print("RSA-3072 modulus :", rsa_key.public_key().key_size // 8, "bytes, signature:", len(rsa_sig), "bytes")والمخرج سوف يكون كما يلي:
Ed25519 public key: 32 bytes, signature: 64 bytes
original message -> valid
modified message -> InvalidSignature
RSA-3072 modulus : 384 bytes, signature: 384 bytesالمفتاح العام في Ed25519 كان 32 بايت والتوقيع 64 بايت، بينما في RSA-3072 كان المعامل 384 بايت والتوقيع 384 بايت أيضاً، وهذا الفرق يظهر في حجم التوكنات والشهادات وزمن التوليد. ولذلك فالاختيار الافتراضي اليوم:
- Ed25519 للتوقيع: المعرف في RFC 8032، سريع وتوقيعه حتمي Deterministic فلا يحتاج رقماً عشوائياً عند كل توقيع، وهذا يزيل نوعاً كاملاً من الأخطاء.
- X25519 لتبادل المفاتيح: المعرف في RFC 7748، وهو ما تستخدمه أغلب اتصالات TLS 1.3 اليوم.
- ECDSA P-256 وRSA-PSS بطول 3072: عندما يفرضها نظام آخر أو معيار أو جهاز لا يدعم Ed25519، وفي RSA استخدم الحشو OAEP للتشفير وPSS للتوقيع، وليس الحشو القديم PKCS#1 v1.5 في أي نظام جديد.
التوقيع ليس تشفيراً Signature vs Encryption
وهنا خلط شائع يجب أن نزيله: في التشفير غير المتماثل يشفر المرسل بالمفتاح العام للمستقبل، فلا يفك الرسالة إلا صاحب المفتاح الخاص، والهدف السرية. أما في التوقيع فالعكس، فصاحب المفتاح الخاص يوقع، وأي شخص يملك المفتاح العام يتحقق، والرسالة نفسها تبقى مقروءة للجميع، والهدف إثبات المصدر وسلامة الرسالة. وفي المخرج أعلاه رأيت التوقيع يمر على الرسالة الأصلية، ثم يفشل بخطأ InvalidSignature عندما تغير رقم الإصدار من v2.4.1 إلى v2.4.2، رغم أن الرسالة لم تكن مخفية أصلاً.
وهذا التوقيع هو ما يحمي Docker Image في سلسلة التوريد كما رأيت مع Cosign في مقال أمن سلسلة التوريد، وما يحمي معاملات البلوكتشين في قسم التوقيع، وما يجعل Passkey لا يرسل أي سر إلى الموقع كما في مقال Passkeys، وما يثبت توكنات RS256 وES256 في JWT.
تبادل المفاتيح Key Exchange والتشفير الهجين Hybrid Encryption
التشفير غير المتماثل بطيء ولا يصلح إلا لمدخلات صغيرة، لذلك لا يشفر به أحد ملفاً من جيجابايت، وإنما يستخدم للاتفاق على مفتاح متماثل ثم يكمل AES أو ChaCha20 العمل، وهذا ما يسمى التشفير الهجين Hybrid Encryption. والمثال التالي يبين الاتفاق على المفتاح مع X25519، ثم يشتق منه مفتاح جلسة بدالة HKDF، والجزء الثاني منه عن تشفير الظرف الذي سوف نشرحه في قسم إدارة المفاتيح:
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
print("== X25519 key exchange")
alice, bob = X25519PrivateKey.generate(), X25519PrivateKey.generate()
s1 = alice.exchange(bob.public_key())
s2 = bob.exchange(alice.public_key())
print("alice shared secret:", s1.hex()[:32], "...")
print("bob shared secret:", s2.hex()[:32], "...")
k = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=b"arabroot demo v1").derive(s1)
print("same secret:", s1 == s2, "| session key from HKDF:", k.hex()[:16], "...")
print("== envelope encryption")
kek_v1 = AESGCM.generate_key(bit_length=256) # lives in the KMS/HSM and never leaves it
dek = AESGCM.generate_key(bit_length=256) # one data key per file or record
n = os.urandom(12)
data = AESGCM(dek).encrypt(n, b"customer export 2026-10.csv ...", None)
wn = os.urandom(12)
wrapped = AESGCM(kek_v1).encrypt(wn, dek, b"kek=v1")
print("stored: ciphertext", len(data), "bytes + wrapped DEK", len(wrapped), "bytes + key id v1")
kek_v2 = AESGCM.generate_key(bit_length=256) # rotation: unwrap with v1, wrap with v2
dek_again = AESGCM(kek_v1).decrypt(wn, wrapped, b"kek=v1")
wn2 = os.urandom(12)
wrapped2 = AESGCM(kek_v2).encrypt(wn2, dek_again, b"kek=v2")
print("rotation to v2: the data stays as is, only the", len(wrapped2), "byte wrapped DEK is replaced")
dek2 = AESGCM(kek_v2).decrypt(wn2, wrapped2, b"kek=v2")
print("read with v2:", AESGCM(dek2).decrypt(n, data, None).decode())والمخرج سوف يكون كما يلي:
== X25519 key exchange
alice shared secret: 754306b3174bac8324fe3aff40e29391 ...
bob shared secret: 754306b3174bac8324fe3aff40e29391 ...
same secret: True | session key from HKDF: dd6e11b2fb3822ac ...
== envelope encryption
stored: ciphertext 47 bytes + wrapped DEK 48 bytes + key id v1
rotation to v2: the data stays as is, only the 48 byte wrapped DEK is replaced
read with v2: customer export 2026-10.csv ...في الجزء الأول لاحظ أن Alice وBob لم يرسلا إلا المفتاحين العامين، ومع ذلك حسب كل منهما السر نفسه، ومن يرى المفتاحين العامين فقط لا يستطيع حسابه. ولاحظ أيضاً أننا لم نستخدم السر الناتج مباشرة كمفتاح AES، وإنما مررناه على HKDF مع وصف لغرضه، لأن ناتج X25519 ليس موزعاً بشكل متساو على كل البتات، وHKDF يحوله إلى مفتاح مناسب ويسمح باشتقاق مفاتيح مختلفة لأغراض مختلفة من السر نفسه. وهذا بالضبط ما يحدث في مصافحة TLS 1.3 التي شرحناها في مقال الشهادات، مع سؤال لا يحله تبادل المفاتيح وحده: هل هذا المفتاح العام فعلاً لمن يدعي أنه له؟ وهنا يأتي التوقيع والشهادة.
الصورة التالية تجمع الأنواع الثلاثة: المتماثل وغير المتماثل والجمع بينهما في تشفير الظرف:

العشوائية Randomness: CSPRNG مقابل random وMath.random
كل ما سبق يعتمد على أرقام عشوائية: الملح والمفتاح وال Nonce ورمز إعادة تعيين كلمة المرور ومعرف الجلسة، فإذا استطاع المخترق أن يتوقع هذه الأرقام سقط كل شيء مهما كانت الخوارزمية قوية. والخطأ الشائع أن يستخدم المطور مولد الأرقام العادي في لغته، مثل random في Python وMath.random في JavaScript وrand في C، وهذه مولدات صممت للمحاكاة والألعاب، فهي سريعة ومتساوية التوزيع لكنها قابلة للتوقع. وقد رأيت هذا الخطأ تحديداً في مقال تحليل الكود الثابت SAST، حيث أضفنا ثغرة VULN-13 فيها رمز إعادة تعيين كلمة المرور يولد بـ random.randint، وأمسكها SonarQube بالقاعدة python:S2245. والسؤال الآن: هل هي ثغرة حقيقية أم تحذير نظري؟
توثيق وحدة random نفسه يقول إنها تعتمد على مولد Mersenne Twister، وإنه غير مناسب إطلاقاً للأغراض التشفيرية. والمثال التالي يبين لماذا: المخترق يرى 624 قيمة من المولد، مثل رموز طلبها لحسابه هو، ثم يعيد بناء حالته الداخلية ويتوقع القيم التالية:
# Python's random is Mersenne Twister (MT19937): 624 outputs of 32 bits reveal its whole state.
import random, secrets
def untemper(y):
y ^= y >> 18
y ^= (y << 15) & 0xEFC60000
x = y
for _ in range(5):
x = y ^ ((x << 7) & 0x9D2C5680)
y = x
x = y
for _ in range(3):
x = y ^ (x >> 11)
return x & 0xFFFFFFFF
server = random.Random() # seeded from the OS, exactly like the module-level random functions
observed = [server.getrandbits(32) for _ in range(624)] # values the attacker saw, e.g. his own tokens
clone = random.Random()
clone.setstate((3, tuple(untemper(v) for v in observed) + (624,), None))
print("next 3 server values :", [server.getrandbits(32) for _ in range(3)])
print("attacker predictions :", [clone.getrandbits(32) for _ in range(3)])
print("next reset code :", server.randint(100000, 999999), "| predicted:", clone.randint(100000, 999999))
print("secrets.token_urlsafe:", secrets.token_urlsafe(32))والمخرج سوف يكون كما يلي:
next 3 server values : [1330436998, 917878504, 2916138287]
attacker predictions : [1330436998, 917878504, 2916138287]
next reset code : 442723 | predicted: 442723
secrets.token_urlsafe: 3NXvx3JedvdlQp54AxFd3erOnMJzu86ao32hvegldcQتوقع المخترق القيم الثلاث التالية بالضبط، ثم توقع رمز إعادة التعيين التالي 442723 الذي سوف يرسل إلى بريد مستخدم آخر، وكل ذلك دون أن يعرف البذرة Seed الأولى التي جاءت من نظام التشغيل. والسبب أن هذا المولد دالة خطية يمكن عكسها، فكل قيمة تخرج منه تكشف جزءاً من حالته. أما المولد الآمن تشفيرياً Cryptographically Secure Pseudo-Random Number Generator واختصاراً CSPRNG فيأخذ عشوائيته من نظام التشغيل، ولا تكشف قيمه السابقة شيئاً عن القادمة، وفي كل لغة له اسم واضح:
| اللغة | لا تستخدم | استخدم |
|---|---|---|
| Python | random | secrets مثل secrets.token_urlsafe(32)، أو os.urandom |
| JavaScript / Node.js | Math.random() | crypto.getRandomValues() في المتصفح، وcrypto.randomBytes() وcrypto.randomUUID() في Node.js |
| C# / .NET | System.Random | RandomNumberGenerator.GetBytes() وRandomNumberGenerator.GetInt32() |
| Java | java.util.Random وMath.random() | java.security.SecureRandom |
| C / Linux | rand() مع srand(time(NULL)) | getrandom() أو /dev/urandom |
وتوثيق Math.random في MDN يحذر صراحة من استخدامه لأي شيء له علاقة بالأمان. ولاحظ أن المولد الآمن قد يكون مكسوراً أيضاً إذا عبث أحد بمصدره، كما في القصة التالية.
إدارة المفاتيح Key Management: أين يعيش المفتاح؟
بعد أن اخترت AES-GCM بشكل صحيح يبقى السؤال الذي يحدد أمان كل ما سبق: أين المفتاح؟ فالتشفير لا يزيل السر، وإنما يحول مشكلة حماية بيانات كبيرة إلى مشكلة حماية مفتاح صغير، فإذا كان المفتاح مكتوباً بجانب البيانات فلم تحم أي شيء.
الطريقة البسيطة: المفتاح في الكود أو ملف الإعداد
أسهل مكان يضع فيه المطور المفتاح هو الكود نفسه، ثم ملف .env، ثم متغير بيئة في ملف Compose داخل المستودع، وكلها تنتهي بالطريقة نفسها: المفتاح في تاريخ Git، أو في طبقة من طبقات ال Docker Image، أو في سجل خط CI، وقد شرحنا طرق التسرب هذه وكيف تكتشفها وماذا تفعل بعدها في مقال اكتشاف الأسرار المسربة. والقاعدة: لا تقم بكتابة أي مفتاح في الكود أو في ملف داخل المستودع، والسبب أن الكود ينسخ ويشارك ويبقى في التاريخ إلى الأبد، والمفتاح يجب أن يكون له مكان واحد يتحكم فيه من يملكه.
KMS وHSM وتشفير الظرف Envelope Encryption
المكان الصحيح للمفتاح الرئيسي هو نظام مخصص لإدارة المفاتيح Key Management Service واختصاراً KMS، أو وحدة أمان عتادية Hardware Security Module واختصاراً HSM، أو خزنة أسرار مثل OpenBao أو Infisical التي قارنا بينها في مقال إدارة الهوية والوصول. والفكرة المهمة في هذه الأنظمة أن المفتاح الرئيسي لا يغادرها أبداً، فالتطبيق يرسل لها البيانات فتشفرها أو تفكها وترد النتيجة، ويسجل كل طلب في سجل تدقيق.
لكن إرسال كل ملف إلى KMS لتشفيره بطيء ومكلف، والحل هو تشفير الظرف Envelope Encryption الذي يصفه توثيق AWS KMS وGoogle Cloud KMS: يولد التطبيق مفتاح بيانات عشوائياً Data Encryption Key واختصاراً DEK لكل ملف أو سجل، ويشفر به البيانات محلياً، ثم يطلب من KMS أن يغلف DEK بالمفتاح الرئيسي Key Encryption Key واختصاراً KEK، ويخزن DEK المغلف بجانب البيانات ويحذف النسخة الصريحة من الذاكرة. وقد رأيت ذلك في الجزء الثاني من مخرج envelope.py أعلاه، فالمخزن هو النص المشفر مع DEK مغلف من 48 بايت ومعرف المفتاح v1، ومن يسرق قاعدة البيانات يحصل على بيانات مشفرة ومفتاح مغلف لا يفيدانه دون طلب إلى KMS، وهذا الطلب يحتاج صلاحية ويسجل.
التدوير Rotation ودورة حياة المفتاح
وميزة الظرف الكبرى تظهر عند تدوير المفتاح، ففي المخرج نفسه انتقلنا من KEK الإصدار v1 إلى v2 دون أن نعيد تشفير البيانات نفسها، وإنما فككنا غلاف DEK بالإصدار القديم وغلفناه بالجديد، أي 48 بايت لكل سجل بدلاً من إعادة تشفير قاعدة البيانات كاملة. ولذلك احفظ دائماً معرف المفتاح Key ID مع كل نص مشفر، حتى يعرف التطبيق بأي إصدار يفك التشفير خلال فترة الانتقال. ويحدد NIST SP 800-57 لكل نوع مفتاح مدة استخدام Cryptoperiod ينتهي بعدها، وتجمع ورقة OWASP لإدارة المفاتيح ما يحتاجه المطور منه. والصورة التالية تبين مراحل حياة المفتاح:

وفي هذه الصورة لاحظ مرحلتين يغفل عنهما كثيرون: التقاعد Retire، حيث يبقى المفتاح القديم متاحاً لفك البيانات القديمة أو التحقق من التوقيعات القديمة فقط، دون أن يستخدم لأي تشفير جديد، والإتلاف Destroy الذي يعني أن كل البيانات المشفرة به تصبح غير قابلة للقراءة، وهذا مفيد عمداً عندما تريد حذف بيانات عميل من كل النسخ الاحتياطية دفعة واحدة، فتتلف مفتاحه فقط، وخطير جداً إذا حدث بالخطأ.
التشفير ما بعد الحوسبة الكمية Post-Quantum Cryptography باختصار
الحاسوب الكمي Quantum Computer الكبير بما يكفي، إذا بني يوماً ما، يستطيع أن يكسر RSA والمنحنيات البيضاوية، ولذلك يضع معيار NIST SP 800-57 علامة على أعمدة RSA وECC في جدوله مع ملاحظة أن تقديرات قوتها سوف تتأثر بشدة عندما تصبح الحوسبة الكمية واقعاً عملياً، بينما لا يضع العلامة نفسها على عمود الخوارزميات المتماثلة. والخطر ليس في المستقبل فقط، فمن يسجل حركة مشفرة اليوم يستطيع أن يفكها بعد سنوات، وهذا ما يسمى «احفظ الآن وفك لاحقاً Harvest Now, Decrypt Later»، وهو مهم للبيانات التي يجب أن تبقى سرية لفترة طويلة.
وفي 13 أغسطس 2024 نشرت NIST أول ثلاثة معايير نهائية:
- FIPS 203 لخوارزمية ML-KEM، وهي لتبادل المفاتيح، ومبنية على CRYSTALS-Kyber.
- FIPS 204 لخوارزمية ML-DSA، وهي التوقيع الرئيسي، ومبنية على CRYSTALS-Dilithium.
- FIPS 205 لخوارزمية SLH-DSA، وهي توقيع احتياطي مبني على الهاش، ومبنية على SPHINCS+.
والانتقال يحدث الآن بالطريقة الهجينة Hybrid، أي أن مصافحة TLS تجمع X25519 التقليدية مع ML-KEM-768 في آلية واحدة اسمها X25519MLKEM768، فإذا ظهر ضعف في إحداهما بقيت الأخرى، وقد أصبحت هذه الآليات معياراً في RFC 10024 في أغسطس 2026، وجعلتها OpenSSL 3.5 من المفاتيح المعروضة افتراضياً في TLS، وبدأ Chrome استخدام ML-KEM بدلاً من Kyber من الإصدار 131 كما في مدونة Google الأمنية. وفي مسودة NIST IR 8547 المنشورة في نوفمبر 2024 تقترح NIST إيقاف التوقيعات وتبادل المفاتيح التقليدية بقوة 112 بت بعد 2030، ومنع RSA وECC كلياً بعد 2035.
وماذا يعني هذا للمطور اليوم؟ لا تكتب ML-KEM بنفسك، وإنما حدث مكتبة TLS ونظام التشغيل، فأغلب الفائدة تأتيك من هناك دون تعديل في الكود. وإذا أردت استخدام هذه الخوارزميات مباشرة فهي موجودة في .NET 10 بالأنواع MLKem وMLDsa وSlhDsa، وفي Java من الإصدار 24 مع JEP 496 وJEP 497. لكن لاحظ في مثال C# أن MLKem.IsSupported أعاد False في Docker Image الرسمية لـ .NET SDK 10، والسبب أن نظامها يحمل OpenSSL 3.0.13، بينما يحتاج .NET نسخة 3.5 أو أحدث على Linux، فالدعم في المكتبة لا يكفي إذا كان نظام التشغيل تحتها قديماً. وأهم خطوة عملية الآن هي المرونة التشفيرية Crypto Agility: اكتب اسم الخوارزمية ومعرف المفتاح مع كل نص مشفر وتوقيع، حتى تستطيع أن تغير الخوارزمية لاحقاً دون أن تفقد بياناتك القديمة.
لماذا لا تخترع خوارزميتك؟ المكتبات Libraries لكل لغة
كل الأخطاء التي رأيتها في هذا المقال حدثت بخوارزميات قوية، فـ AES لم ينكسر في تجربة ECB، وGCM لم ينكسر عندما تكرر ال Nonce، وMersenne Twister يعمل تماماً كما صمم. والمشكلة كانت دائماً في طريقة الاستخدام: وضع خاطئ، أو رقم تكرر، أو مولد لغرض آخر، أو مقارنة بـ ==. فإذا كانت هذه الأخطاء تحدث مع الخوارزميات المعروفة، فتخيل ما يحدث مع خوارزمية كتبها مطور في أسبوع، دون أن يراجعها أحد ممن يعرفون كيف تكسر. وقد يبدو لك أن الخوارزمية «السرية» أصعب على المخترق، والإجابة أن الأمان يجب أن يأتي من سرية المفتاح وحده، أما الخوارزمية فتفترض أن المخترق يعرفها، وهذا ما يجعل AES مفتوحاً للجميع منذ عقود ولم يكسره أحد.
والقاعدة العملية: لا تقم بكتابة خوارزمية تشفير، ولا تجمع الأجزاء المنخفضة المستوى بنفسك إذا وجدت دالة عالية المستوى تفعل ما تريد، مثل PasswordHasher في argon2-cffi أو AESGCM في cryptography أو crypto_secretbox في libsodium، لأن هذه الدوال تختار الوضع والطول والحشو نيابة عنك.
جدول المكتبات Libraries وتراخيصها
| اللغة | التشفير والتوقيع | كلمات المرور | ملاحظات |
|---|---|---|---|
| أي لغة | libsodium بترخيص ISC، وله واجهات في أغلب اللغات | crypto_pwhash بـ Argon2id | واجهة عالية المستوى صعبة الاستخدام الخاطئ |
| Python | cryptography بترخيص Apache-2.0 أو BSD، وPyNaCl بترخيص Apache-2.0 فوق libsodium | argon2-cffi بترخيص MIT، وArgon2id في cryptography من الإصدار 44.0.0 | Argon2id في cryptography يحتاج OpenSSL 3.2 أو أحدث |
| C# / .NET | المدمجة System.Security.Cryptography: AesGcm وChaCha20Poly1305 وECDsa وRSA، وفي .NET 10 MLKem وMLDsa | Rfc2898DeriveBytes.Pbkdf2 مدمجة، وArgon2id عبر NSec أو Konscious.Security.Cryptography.Argon2، وكلاهما MIT | لا يوجد Ed25519 ولا Argon2 مدمجان حتى .NET 10، وNSec يوفر الاثنين فوق libsodium |
| Java | JCA المدمجة: AES/GCM/NoPadding وEd25519 من Java 15 مع JEP 339 وX25519 من Java 11 مع JEP 324، وML-KEM وML-DSA من Java 24 | PBKDF2 مدمج، وArgon2id عبر Argon2BytesGenerator في Bouncy Castle بترخيص MIT | حدد الوضع دائماً، فـ "AES" وحدها تعني ECB |
| Node.js | الوحدة المدمجة node:crypto: aes-256-gcm وEd25519 وX25519 | crypto.argon2 مدمجة من Node.js 24.7.0، أو حزمة argon2 بترخيص MIT للإصدارات الأقدم | في المتصفح استخدم Web Crypto API |
والنقطة الأخيرة في الجدول تحققنا منها مباشرة، فالمثال التالي يحسب Argon2id بالدالة المدمجة في Node.js:
import { argon2Sync, randomBytes } from 'node:crypto';
const t = performance.now();
const hash = argon2Sync('argon2id', { message: 'S3cret!', nonce: randomBytes(16), parallelism: 1, memory: 19456, passes: 2, tagLength: 32 });
console.log(`Node ${process.version} OpenSSL ${process.versions.openssl}: argon2id ${hash.length} bytes in ${(performance.now() - t).toFixed(1)} ms`);docker run --rm --name lab-crypto-node -v "$PWD:/lab" node:26-alpine node /lab/argon2.mjs
docker run --rm --name lab-crypto-node22 -v "$PWD:/lab" node:22-alpine node /lab/argon2.mjsوالمخرج سوف يكون كما يلي:
Node v26.10.0 OpenSSL 3.5.8: argon2id 32 bytes in 32.9 ms
file:///lab/argon2.mjs:1
import { argon2Sync, randomBytes } from 'node:crypto';
^^^^^^^^^^
file:///lab/argon2.mjs:1
import { argon2Sync, randomBytes } from 'node:crypto';
^^^^^^^^^^
SyntaxError: The requested module 'node:crypto' does not provide an export named 'argon2Sync'
at ModuleJob._instantiate (node:internal/modules/esm/module_job:226:21)
at async ModuleJob.run (node:internal/modules/esm/module_job:335:5)
at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:681:26)
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5)
Node.js v22.23.3نجح Node.js 26 في 32.9 مللي ثانية، قريباً من رقم argon2-cffi في Python لأن كلاهما يستدعي كوداً أصلياً، أما Node.js 22 فلم يجد الدالة أصلاً، لذلك إذا كان مشروعك على إصدار أقدم من 24.7.0 فاستخدم حزمة argon2، ولا تقم بتمرير الكلمة على crypto.pbkdf2 بعدد تكرارات قليل لأنها «موجودة».
مثال C#: AesGcm وPBKDF2 وML-KEM في .NET 10
المثال التالي ملف Program.cs كامل لمشروع Console، يشفر بـ AesGcm ويعبث بالنص، ثم يحسب PBKDF2 بمعاملات OWASP، ثم يجرب ML-KEM:
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
Console.WriteLine($".NET {Environment.Version}");
// AES-256-GCM with System.Security.Cryptography.AesGcm
byte[] key = RandomNumberGenerator.GetBytes(32);
byte[] nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize); // 12 bytes
byte[] aad = Encoding.UTF8.GetBytes("user_id=42");
byte[] plain = Encoding.UTF8.GetBytes("رقم الهوية 1234567890");
byte[] cipher = new byte[plain.Length];
byte[] tag = new byte[16];
using var aes = new AesGcm(key, tagSizeInBytes: 16);
aes.Encrypt(nonce, plain, cipher, tag, aad);
Console.WriteLine($"ciphertext: {Convert.ToHexString(cipher)} tag: {Convert.ToHexString(tag)}");
byte[] back = new byte[cipher.Length];
aes.Decrypt(nonce, cipher, tag, back, aad);
Console.WriteLine($"decrypt : {Encoding.UTF8.GetString(back)}");
cipher[0] ^= 1; // flip one bit
try { aes.Decrypt(nonce, cipher, tag, back, aad); }
catch (AuthenticationTagMismatchException e) { Console.WriteLine($"one bit flipped -> {e.GetType().Name}"); }
// PBKDF2-HMAC-SHA256 with the OWASP iteration count (FIPS-friendly password hashing)
byte[] salt = RandomNumberGenerator.GetBytes(16);
var sw = Stopwatch.StartNew();
byte[] hash = Rfc2898DeriveBytes.Pbkdf2("S3cret!", salt, 600_000, HashAlgorithmName.SHA256, 32);
sw.Stop();
Console.WriteLine($"PBKDF2-SHA256 600000 iterations: {sw.ElapsedMilliseconds} ms, hash {Convert.ToHexString(hash)[..16]}...");
Console.WriteLine($"same password, same salt, FixedTimeEquals: {CryptographicOperations.FixedTimeEquals(hash, Rfc2898DeriveBytes.Pbkdf2("S3cret!", salt, 600_000, HashAlgorithmName.SHA256, 32))}");
// Post-quantum: ML-KEM is in .NET 10 when the OS crypto library supports it (OpenSSL 3.5+)
Console.WriteLine($"MLKem.IsSupported: {MLKem.IsSupported}");
if (MLKem.IsSupported)
{
using MLKem server = MLKem.GenerateKey(MLKemAlgorithm.MLKem768);
byte[] encapKey = server.ExportEncapsulationKey();
using MLKem client = MLKem.ImportEncapsulationKey(MLKemAlgorithm.MLKem768, encapKey);
client.Encapsulate(out byte[] ciphertext, out byte[] secretClient);
byte[] secretServer = server.Decapsulate(ciphertext);
Console.WriteLine($"ML-KEM-768 encapsulation key {encapKey.Length} bytes, ciphertext {ciphertext.Length} bytes, same secret: {secretClient.AsSpan().SequenceEqual(secretServer)}");
}ونشغله في Docker Image الرسمية لـ .NET SDK 10:
docker run --rm -it --name lab-crypto-dotnet -v "$PWD:/lab" mcr.microsoft.com/dotnet/sdk:10.0 bash
# داخل ال Container
cd /tmp && dotnet new console -o app && cp /lab/Program.cs app/ && cd app
dotnet --version && openssl version
dotnet run -c Releaseوالمخرج سوف يكون كما يلي:
10.0.401
OpenSSL 3.0.13 30 Jan 2024 (Library: OpenSSL 3.0.13 30 Jan 2024)
.NET 10.0.12
ciphertext: B6982D23C4E8B63DFB6B4EE5BA5461198812C08505290C4943971CDAAC75 tag: DF44B8114CC1EEC8537084077A1B7480
decrypt : رقم الهوية 1234567890
one bit flipped -> AuthenticationTagMismatchException
PBKDF2-SHA256 600000 iterations: 337 ms, hash 9D16757AB1130D78...
same password, same salt, FixedTimeEquals: True
MLKem.IsSupported: Falseفي الإعداد أعلاه لاحظ التالي:
- في
AesGcmيحجز المطور مكان النص المشفر والوسم بنفسه، وطول الوسم يحدد في الإنشاء بـtagSizeInBytes: 16، وال Nonce منRandomNumberGeneratorوطوله منAesGcm.NonceByteSizes.MaxSizeأي 12 بايت. - العبث ببت واحد أعطى
AuthenticationTagMismatchException، وهو المقابل في .NET لـInvalidTagفي Python. - استخدمنا الدالة الثابتة
Rfc2898DeriveBytes.Pbkdf2وليسnew Rfc2898DeriveBytes(...)، والسبب أن البناة Constructors في هذا الكلاس أصبحت متروكة في .NET 10 بالتحذير SYSLIB0060، وعندما جربنا الباني القديمnew Rfc2898DeriveBytes("x", 16)كانت قيمه الافتراضية SHA1 مع 1000 تكرار فقط، وهي قيم من زمن آخر. - المقارنة بـ
CryptographicOperations.FixedTimeEqualsوليس بـSequenceEqualلنفس سببhmac.compare_digestفي Python. MLKem.IsSupportedأعادFalseلأن نظام ال Docker Image يحمل OpenSSL 3.0.13 كما في السطر الثاني من المخرج، فالكود صحيح لكنه لن يعمل حتى يعمل التطبيق على نظام فيه OpenSSL 3.5 أو أحدث، ولذلك يفحص الكودIsSupportedقبل الاستخدام.
وللتحقق من آخر نقطتين فحصنا الأنواع المصدرة في مكتبة التشفير نفسها، فوجدنا ChaCha20Poly1305 وMLDsa وMLKem وSlhDsa مع نسخها لـ OpenSSL وCNG، ولم نجد أي نوع باسم Ed25519 أو Argon2، وهذا ما يجعل NSec أو Konscious ضرورياً لهما في .NET.
مثال Java: AES-GCM وEd25519 في JCA
وفي Java يكفي ملف واحد يشغل مباشرة دون Maven، فمنذ Java 11 يستطيع الأمر java أن يترجم ملف المصدر ويشغله كما في JEP 330:
import javax.crypto.AEADBadTagException;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.util.HexFormat;
public class CryptoLab {
public static void main(String[] args) throws Exception {
System.out.println("Java " + System.getProperty("java.version"));
// AES-256-GCM: a new 12-byte nonce from SecureRandom for every message
KeyGenerator kg = KeyGenerator.getInstance("AES");
kg.init(256);
SecretKey key = kg.generateKey();
byte[] nonce = new byte[12];
SecureRandom.getInstanceStrong().nextBytes(nonce);
Cipher enc = Cipher.getInstance("AES/GCM/NoPadding");
enc.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, nonce));
enc.updateAAD("user_id=42".getBytes(StandardCharsets.UTF_8));
byte[] ct = enc.doFinal("رقم الهوية 1234567890".getBytes(StandardCharsets.UTF_8));
System.out.println("ciphertext+tag: " + ct.length + " bytes " + HexFormat.of().formatHex(ct));
System.out.println("decrypt : " + decrypt(key, nonce, ct));
ct[0] ^= 1; // flip one bit
try {
decrypt(key, nonce, ct);
} catch (AEADBadTagException e) {
System.out.println("one bit flipped -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
// Ed25519 is built into the JDK since Java 15
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
Signature s = Signature.getInstance("Ed25519");
s.initSign(kp.getPrivate());
byte[] msg = "release v2.4.1".getBytes(StandardCharsets.UTF_8);
s.update(msg);
byte[] sig = s.sign();
System.out.println("Ed25519 signature: " + sig.length + " bytes");
System.out.println("original message -> " + verify(kp.getPublic(), msg, sig));
System.out.println("modified message -> " + verify(kp.getPublic(), "release v2.4.2".getBytes(StandardCharsets.UTF_8), sig));
}
static String decrypt(SecretKey key, byte[] nonce, byte[] ct) throws Exception {
Cipher dec = Cipher.getInstance("AES/GCM/NoPadding");
dec.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, nonce));
dec.updateAAD("user_id=42".getBytes(StandardCharsets.UTF_8));
return new String(dec.doFinal(ct), StandardCharsets.UTF_8);
}
static boolean verify(PublicKey pub, byte[] msg, byte[] sig) throws Exception {
Signature v = Signature.getInstance("Ed25519");
v.initVerify(pub);
v.update(msg);
return v.verify(sig);
}
}docker run --rm --name lab-crypto-java -e LANG=C.UTF-8 -v "$PWD:/lab" eclipse-temurin:21-jdk-alpine java -Dstdout.encoding=UTF-8 /lab/CryptoLab.javaوالمخرج سوف يكون كما يلي:
Java 21.0.12.1
ciphertext+tag: 46 bytes dd80ba7a5387d057dfa6a3e5b763bc6d7878d191d85685eea113a49996b318a1330c0e056d70fe60263c12c0edd0
decrypt : رقم الهوية 1234567890
one bit flipped -> AEADBadTagException: Tag mismatch
Ed25519 signature: 64 bytes
original message -> true
modified message -> falseفي الإعداد أعلاه لاحظ التالي:
- كتبنا الوضع كاملاً
AES/GCM/NoPadding، ولم نكتب"AES"وحدها التي تعني ECB في مزود Java الافتراضي. - طول الوسم يمرر بالبت في
new GCMParameterSpec(128, nonce)، والعبث أعطىAEADBadTagException: Tag mismatch. - ال Nonce من
SecureRandomوليس منjava.util.Random. - Ed25519 مدمج في JDK دون أي مكتبة خارجية، وعكس Python يعيد
verifyهناfalseبدلاً من رمي استثناء، فلا تنس أن تفحص القيمة المرجعة.
ماذا تستخدم لكل مهمة؟ جدول القرار
الصورة التالية تجمع ما سبق في جدول واحد ترجع إليه عندما تكتب الكود:

أخطاء شائعة Common Mistakes
- تخزين كلمات المرور بـ SHA-256 أو MD5 ولو مع Salt: الملح يمنع الجداول الجاهزة لكنه لا يبطئ التخمين، فنواة واحدة جربت 600 ألف كلمة في الثانية في تجربتنا، والحل Argon2id.
- تشفير كلمات المرور بدلاً من الهاش: من يصل إلى المفتاح يفك كل الكلمات دفعة واحدة، كما في حادثة Adobe.
- المقارنة بـ
==: عند فحص HMAC أو رمز أو بصمة، والحلcompare_digestأوFixedTimeEqualsأوMessageDigest.isEqualأوtimingSafeEqual. - Nonce ثابت أو محسوب من المعرف: مثل استخدام رقم الصف أو صفر، وتكراره مع المفتاح نفسه يكشف النصوص ويسمح بالتزوير في GCM.
Cipher.getInstance("AES")في Java: يعطيك ECB بصمت، وصورة القفل في تجربتنا تبين ما يعنيه ذلك.- bcrypt مع نص طويل: دمج اسم المستخدم أو أي بادئة مع كلمة المرور قبل bcrypt يدفع الكلمة خارج أول 72 بايت، كما في ثغرة Okta.
randomأوMath.randomلأي قيمة أمنية: رمز إعادة تعيين أو توكن أو ملح أو مفتاح، والمولد قابل للتوقع كما رأيت بعد 624 قيمة فقط.- المفتاح بجانب البيانات: في الكود أو
.envداخل المستودع أو ال Docker Image، فالتشفير هنا شكلي فقط. - نص مشفر دون معرف المفتاح والخوارزمية: فيصبح التدوير والانتقال إلى خوارزمية جديدة مستحيلاً دون فك كل البيانات القديمة.
- اعتبار Base64 أو التوقيع وسيلة إخفاء: Base64 يفك بأمر واحد، والرسالة الموقعة مقروءة للجميع.
- مكتبة تشفير مكتوبة يدوياً أو منسوخة من إجابة قديمة: أغلب الإجابات القديمة تستخدم ECB أو CBC دون MAC أو MD5، فارجع إلى توثيق المكتبة الحالية.
قائمة تحقق Checklist
- كلمات المرور بـ Argon2id بمعاملات لا تقل عن
m=19456, t=2, p=1، أو PBKDF2-HMAC-SHA256 بـ 600,000 تكرار إذا كان FIPS مطلوباً، مع إعادة الحساب عند الدخول إذا تغيرت المعاملات. - إذا بقي bcrypt في نظام قديم: عامل عمل 10 أو أكثر، وحد أقصى للكلمة 72 بايت محسوب بالبايت وليس بالحرف، ولا شيء يضاف قبل كلمة المرور.
- تحديد عدد محاولات الدخول، وفلفل في خزنة أسرار إذا كان مناسباً لنظامك.
- الهاش للبصمات بـ SHA-256 أو أحدث، ولا يوجد MD5 أو SHA-1 في أي مكان أمني.
- الرسائل بين الخدمات وال Webhooks بـ HMAC-SHA256 ومقارنة في زمن ثابت.
- التشفير بـ AES-256-GCM أو ChaCha20-Poly1305 فقط، مع Nonce لا يتكرر، وبيانات مرتبطة تربط النص المشفر بصاحبه.
- التوقيع بـ Ed25519، أو ECDSA P-256 أو RSA-PSS بطول 3072 عند الحاجة، ولا يوجد RSA-2048 في أي نظام جديد يجب أن يعيش بعد 2030.
- كل قيمة عشوائية أمنية من CSPRNG، وقاعدة SAST تمنع
randomوMath.randomفي الكود الأمني. - المفاتيح في KMS أو HSM أو OpenBao، مع تشفير الظرف، ومعرف المفتاح محفوظ مع كل نص مشفر، وخطة تدوير مكتوبة، وفحص أسرار في خط CI.
- مكتبة TLS ونظام التشغيل محدثان، والخوارزمية قابلة للتغيير دون إعادة كتابة التطبيق.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- الهاش يجيب عن «هل تغيرت؟»، وHMAC يضيف «ومن أحدنا؟»، والتشفير يجيب عن «من يقرأ؟»، والتوقيع عن «من كتب؟»، والخطأ الأشهر أن تستخدم واحدة منها لسؤال الأخرى.
- MD5 وSHA-1 سقطا في مقاومة التصادم منذ SHAttered في 2017، والبديل SHA-256 أو SHA-3 أو BLAKE2 وBLAKE3، لكن لا شيء منها يصلح لكلمات المرور لأنها سريعة.
- كلمات المرور تخزن بـ Argon2id مع ملح لكل مستخدم بمعاملات OWASP
m=19456, t=2, p=1كحد أدنى، وbcrypt يقرأ 72 بايت فقط، وPBKDF2 بـ 600,000 تكرار عندما يفرض FIPS. - التشفير المتماثل اليوم هو AES-GCM أو ChaCha20-Poly1305، والوسم يرفض أي تعديل، وتكرار ال Nonce مع المفتاح نفسه كشف رسالة التحويل الثانية كاملة في تجربتنا، وECB أبقى شكل الصورة واضحاً بعد التشفير.
- غير المتماثل للاتفاق على المفتاح وللتوقيع، وEd25519 وX25519 بمفاتيح من 32 بايت تعطي أماناً أكبر من RSA-2048، وقوة 112 بت مقبولة حتى نهاية 2030 فقط بحسب NIST.
- العشوائية الأمنية من CSPRNG فقط، فقد توقعنا رمز إعادة التعيين التالي من
randomبعد 624 قيمة. - المفتاح في KMS أو HSM أو خزنة أسرار، مع تشفير الظرف ومعرف المفتاح والتدوير، والمرونة التشفيرية تجهزك للانتقال إلى ML-KEM وML-DSA بعد معايير FIPS 203 و204 و205.
- لا تخترع خوارزميتك، واستخدم الدوال عالية المستوى في libsodium وcryptography وSystem.Security.Cryptography وJCA وnode:crypto.
وكثير من هذه القواعد تجده على شكل متطلبات يمكن التحقق منها في معيار ASVS، وقد شرحنا كيف تحولها إلى أسئلة تراجعها في كل طلب دمج في مقال متطلبات الأمان ومراجعة الكود مع OWASP ASVS.
دليلالترميز Encoding للمطورين: Base64 وأخواتهلماذا نحول البايتات إلى حروف حتى تمر في البريد والروابط، وكيف تفك أي سلسلة منها، ولماذا لا يحمي Base64 أي سر.
دليلالشهادات الرقمية TLS للمبتدئين وصيغ ملفاتهاكيف يعرف المتصفح أنه يتحدث مع الموقع الحقيقي، وماذا يوجد داخل الشهادة، ولماذا تأتي ملفاتها بأكثر من صيغة.
دليلكيف يتذكرك الموقع بعد تسجيل الدخول؟ الجلسات وJWTبروتوكول HTTP لا يتذكر أحداً، فكيف يبقى المستخدم مسجلاً بين الصفحات، ومتى تختار الجلسة على السيرفر ومتى تختار JWT.
دليلالدخول دون كلمة مرور: شرح Passkeys وWebAuthnلماذا لا تحميك رموز SMS وTOTP من التصيد الحديث، وكيف تحل Passkeys المشكلة بمفتاح خاص لا يغادر جهازك.
دليلOWASP Top 10 للمبتدئين: أخطر عشر مشكلات في تطبيقات الويبأخطر عشر مشكلات أمنية في تطبيقات الويب، كل واحدة بمثال ضعيف ثم مصلح، وكيف تمنعها في الكود وأين تكتشفها.
دليلالبرمجة الآمنة Secure Coding بأمثلة من أربع لغاتكيف تكتب الكود الذي يتعامل مع مدخلات المستخدم والملفات والأخطاء بحيث لا يحتاج إصلاحاً أمنياً لاحقاً، بأمثلة في أربع لغات.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على Python 3.12.15 مع cryptography 50.0.2 وargon2-cffi 25.1.0 وbcrypt 5.0.0، و.NET SDK 10.0.401، وEclipse Temurin 21.0.12، وNode.js 26.10.0 و22.23.3، والتحقق من معاملات Argon2id في ورقة OWASP وRFC 9106، وأطوال المفاتيح في NIST SP 800-57، ومعايير FIPS 203 و204 و205 وRFC 10024.