קריפטוגרפיה
הצפנה, קידוד וגיבוב: מה כל אחד מהם עושה
הפרדה בין קידוד, הצפנה וגיבוב במעבדות עם Base64, SHA-256 ו-OpenSSL. למה Base64 אינו הגנה ולמה hash הוא חד־כיווני.
למה hashes מהירים נכשלים בסיסמאות, מה salt ו-work factor עושים, ואיך מחשבים ומאמתים Argon2id ב-Python עם argon2-cffi.
מסדי סיסמאות דולפים יותר ממה שצוותים מתכננים. כשזה קורה, התוקף מקבל hashes ולא סיסמאות. כמה עבודה נדרשת כדי לעבור מהאחד לשני תלוי בשתי בחירות: הפונקציה שנבחרה וההגדרות שניתנו לה. המאמר מסביר למה אחסון סיסמאות צריך סוג אחר של hash, מה salt ו-work factor עושים, ולמה ההנחיות הנוכחיות מצביעות על Argon2id. במעבדה מחשבים ומאמתים סיסמה ב-Python, קוראים כל חלק ב-hash המקודד ומחשבים מחדש סיסמאות שמורות כשההגדרות משתנות.
צריך טרמינל ו-Python 3. המעבדה מתקינה חבילה אחת, argon2-cffi.
פונקציית hash הופכת קלט לפלט בגודל קבוע. SHA-256 עושה את זה מהר מאוד, וזה מה שרוצים ב-checksums ובשלמות קבצים. בסיסמאות המהירות היא הבעיה. טבלת סיסמאות גנובה מותקפת בניחושים: לוקחים מועמד, מחשבים hash, משווים, חוזרים חלילה. אם כל ניחוש זול, התוקף יכול להריץ מיליארדים מהם על כרטיסים גרפיים.
OWASP Password Storage Cheat Sheet אומר את זה ישירות. SHA-256 מהיר, ומהירות היא בדיוק מה שתוקף צריך כשהקלט הוא סיסמה, כלומר מחרוזת קצרה שאדם בחר.
אפשר למדוד את הפער על המחשב שלך. המדידה במעבדה מחשבת hash לסיסמה פעם עם SHA-256 ו-salt ופעם עם Argon2id, ומדפיסה את הקצב של כל אחת.
salt הוא ערך אקראי, שונה לכל סיסמה, שנשמר ליד ה-hash. יש לו שני תפקידים.
salt הוא לא סוד, והוא לא הופך סיסמה חלשה לחזקה. התפקיד שלו הוא למנוע מהתוקף שימוש חוזר בעבודה בין משתמשים. RFC 9106 ממליץ על salt של 128 ביט ומתיר 64 ביט כשהמקום צפוף. NIST SP 800-63B דורש לפחות 32 ביט. ברירת המחדל של argon2-cffi היא 16 בתים, כלומר 128 ביט.
work factor הוא הגדרה שמייקרת כל ניחוש בכוונה. שיטות ישנות כמו PBKDF2 סופרות בעיקר איטרציות. ל-Argon2 יש שלושה פרמטרים.
m): ה-RAM, ב-KiB, שכל hash משתמש בו.t): כמה מעברים הפונקציה עושה על הזיכרון הזה.p): כמה נתיבים יכולים לרוץ בו־זמנית.הזיכרון חשוב יותר מכול. ל-GPU יש אלפי ליבות קטנות, ולכל ליבה יש מעט זיכרון משלה. אם כל ניחוש צריך עשרות מגה־בייט, התוקף מריץ במקביל פחות ניחושים משהיה מריץ מול פונקציה שצריכה כמה קילו־בייט. התכונה הזאת נקראת memory-hardness.
ה-work factor הוא חוגה ולא קבוע. מעלים אותו ככל שהחומרה מתקדמת וככל שתקציב ההתחברות מאפשר. שומרים את ההגדרות עם כל hash, כדי שאפשר יהיה לזהות hashes ישנים ולשדרג אותם מאוחר יותר. השלבים האחרונים של המעבדה עושים את זה.
Argon2 ניצח ב-Password Hashing Competition בשנת 2015. יש לו שלושה וריאנטים.
Argon2id מכסה את שני החששות. RFC 9106 ממליץ עליו כווריאנט לשימוש כללי, ו-OWASP מציב אותו ראשון באפשרויות ה-hashing לסיסמאות.
RFC 9106 נותן שתי הגדרות מומלצות. הראשונה, למערכות עם זיכרון פנוי, היא 2 GiB, 1 מעבר ו-4 נתיבים. השנייה, לסביבות עם זיכרון מוגבל, היא 64 MiB, 3 מעברים ו-4 נתיבים. המינימום של OWASP הוא 19 MiB, 2 איטרציות ו-1 נתיב. OWASP מונה גם חלופות שמחליפות זיכרון במעברים, כמו 12 MiB עם 3 מעברים או 7 MiB עם 5 מעברים, כולן עם 1 נתיב. ברירות המחדל של argon2-cffi תואמות לסט השני של ה-RFC.
אלה רצפות ונקודות התחלה, ולא יעדים. כלל אצבע נפוץ הוא לכוונן את ההגדרות עד ש-hash אחד לוקח כמה מאות אלפיות שנייה על חומרת הפרודקשן. אחר כך בודקים שהזיכרון לכל hash, כפול מספר ההתחברויות שיכולות להגיע באותו רגע, עדיין מתאים לשרתים. הכלל הזה בא מהניסיון, ולא מאחד התקנים.
NIST ניגש לזה אחרת. SP 800-63B דורש שסיסמאות יקבלו salt ו-hash בשיטה מתאימה, ואומר שיש להשתמש בשיטה מאושרת מ-SP 800-132, שמכסה את PBKDF2. הוא לא מזכיר את Argon2id. אם תוכנית ציות דורשת אלגוריתמים ש-NIST אישר, כדאי לוודא מול המבקר לפני הבחירה ב-Argon2id.
כשספריית hashing שומרת סיסמה, קורים חמישה דברים.
בגלל שלב 3 לא צריך עמודת salt נפרדת. למחרוזת המקודדת יש הפורמט הזה.
$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>argon2id הוא הווריאנט.v=19 היא גרסת Argon2, כלומר 1.3, בכתיב עשרוני. בהקסדצימלי זה 0x13.m, t וגם p הם הזיכרון ב-KiB, עלות הזמן והמקביליות.<salt> וגם <hash> הם base64 בלי padding. ה-hash הוא הפלט שנגזר, 32 בתים כברירת מחדל.המחרוזת מהמעבדה ארוכה 97 תווים. צריך עמודה שמחזיקה לפחות 255 תווים, או טיפוס text. לא מפצלים אותה לעמודות, אלא אם כתיבת parser משלך נראית לך כמו בילוי.
המעבדה משתמשת בסביבה וירטואלית מקומית ולא צריכה שירות רשת. הפלט בהמשך מגיע ממחשב Windows 11 אחד עם Python 3.12.10, argon2-cffi 25.1.0 ו-argon2-cffi-bindings 26.1.0. במחשב הזה python3 לא היה ב-PATH של Git Bash, ולכן הפקודות רצו בשם python. אם python3 עובד אצלך, אפשר להשתמש בו. מחרוזות ה-hash אצלך יהיו שונות מאלה שמופיעות כאן, כי ה-salt אקראי.
python3 -m venv .venv && . .venv/bin/activate
pip install argon2-cffiב-Windows מפעילים עם .venv\Scripts\activate ב-PowerShell או ב-cmd, או עם . .venv/Scripts/activate ב-Git Bash.
python3 -c "from argon2 import PasswordHasher; ph=PasswordHasher(); h=ph.hash('correct horse battery'); print(h); print(ph.verify(h,'correct horse battery')); print(ph.check_needs_rehash(h))"הפלט שהתקבל:
$argon2id$v=19$m=65536,t=3,p=4$abVC6VF2EGJW1SCX/D6WBQ$XHKggONKypxBhdq7sWTbL/sMkglTAKu9y1FlL5p4dic
True
Falseverify מחזיר True עבור הסיסמה הנכונה. check_needs_rehash מחזיר False כי ה-hash הזה נוצר עם ברירות המחדל הנוכחיות. ברירות המחדל הן עלות זמן של 3, עלות זיכרון של 65536 KiB (64 MiB), מקביליות של 4, hash של 32 בתים, salt של 16 בתים והווריאנט ID.
python3 -c "from argon2 import PasswordHasher; from argon2.exceptions import VerifyMismatchError; ph=PasswordHasher(); h=ph.hash('a');
try: ph.verify(h,'b')
except VerifyMismatchError: print('mismatch detected')"הפלט שהתקבל:
mismatch detectedסיסמה שגויה לא מחזירה False. verify זורק VerifyMismatchError, ולכן הבדיקה שייכת לבלוק except. מחרוזת שמורה פגומה זורקת חריג אחר, InvalidHashError. מטפלים בזה כבעיית נתונים בצד השרת, ולא נותנים לזה להגיע למסך של המשתמש.
מחשבים hash לאותה סיסמה פעמיים.
python3 -c "from argon2 import PasswordHasher; ph=PasswordHasher(); h=ph.hash('correct horse battery'); print(h)"
python3 -c "from argon2 import PasswordHasher; ph=PasswordHasher(); h=ph.hash('correct horse battery'); print(h)"הפלט שהתקבל:
$argon2id$v=19$m=65536,t=3,p=4$p8OU0plYOCO2/v70mjYTFA$FwhMW3ECJ0F/8rulKrJ158HjyNekURguaCIfoVk0i/o
$argon2id$v=19$m=65536,t=3,p=4$MTgAgrtBHJmc1eL2iKy8nA$h/7+8J6Cdb82XTBGWsLjbYBIh05934Yel4y6tyZ6NYgסיסמה אחת, שתי מחרוזות שונות. הפרמטרים זהים כי שתי ההרצות השתמשו באותן ברירות מחדל. רק ה-salt והפלט שונים. מי שקורא את הטבלה לא יכול לדעת ששני החשבונות האלה משתמשים באותה סיסמה.
שומרים את זה בשם parts.py.
from argon2 import PasswordHasher
h = PasswordHasher().hash("correct horse battery")
print(h.split("$"))מריצים עם python3 parts.py. הפלט שהתקבל:
['', 'argon2id', 'v=19', 'm=65536,t=3,p=4', 'waS+Ec4iQCNIuhRQbv2sWQ', 'yLioGsHJoVv+vTu2kRoYmXTLjbbjJSe2tPSExz3Sd5A']האיבר הראשון הריק מגיע מהתו $ שבהתחלה. אחריו באים הווריאנט, הגרסה, הפרמטרים, ה-salt וה-hash. האימות לא צריך שום דבר אחר, ולכן עמודה אחת מספיקה.
הגדרות משתנות עם הזמן. הסקריפט הזה יוצר hash עם הגדרות ישנות וחלשות יותר ואז בודק אותו מול ברירות המחדל הנוכחיות. שומרים אותו בשם rehash.py.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
old = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
stored = old.hash('correct horse battery')
print('stored:', stored)
new = PasswordHasher() # current defaults
print('needs rehash under new params:', new.check_needs_rehash(stored))
try:
new.verify(stored, 'correct horse battery')
print('verify ok')
if new.check_needs_rehash(stored):
stored = new.hash('correct horse battery')
print('rehashed:', stored)
except VerifyMismatchError:
print('wrong password')
print('after rehash needs rehash:', new.check_needs_rehash(stored))מריצים python3 rehash.py. הפלט שהתקבל:
stored: $argon2id$v=19$m=19456,t=2,p=1$EWji+p6+3HbAKY+wkykcXg$NxPq/zGPsx9okK4vQCKfZ4BVoSOqeF1QkHxd3f2g2uE
needs rehash under new params: True
verify ok
rehashed: $argon2id$v=19$m=65536,t=3,p=4$FkifxeWUHV9eU7d8JFt6mg$ZFoMkNjYqo4D/bax/jPz80Hvr4mjLWRi4TVoqCoR6ng
after rehash needs rehash: Falseהסדר חשוב. קודם מאמתים ואז מחשבים מחדש, כך שרק סיסמה נכונה מגיעה לשדרוג. באפליקציה אמיתית כותבים את המחרוזת החדשה בחזרה לאותה שורה מיד אחרי התחברות מוצלחת. בלי השלב הזה, ההגדרות הישנות נשארות בטבלה ללא הגבלת זמן.
שומרים את זה בשם bench.py.
import hashlib, time, os
from argon2 import PasswordHasher
pw = b'correct horse battery'
salt = os.urandom(16)
n = 1_000_000
t = time.perf_counter()
for _ in range(n):
hashlib.sha256(salt + pw).digest()
dt = time.perf_counter() - t
print(f"sha256 salted: {n} hashes in {dt:.2f} s, {n/dt:,.0f} per second")
ph = PasswordHasher()
m = 10
t = time.perf_counter()
for _ in range(m):
ph.hash('correct horse battery')
dt = time.perf_counter() - t
print(f"argon2id default: {m} hashes in {dt:.2f} s, {dt/m*1000:.0f} ms per hash")מריצים python3 bench.py. הפלט שהתקבל:
sha256 salted: 1000000 hashes in 4.06 s, 246,343 per second
argon2id default: 10 hashes in 6.40 s, 640 ms per hashאלה הרצות בודדות על מחשב Windows אחד. שורת ה-SHA-256 מודדת לולאת Python ולא קוד ממוטב, ולכן היא ממעיטה במה שחומרה ייעודית עושה עם SHA-256. שורת ה-Argon2id היא העלות שכל התחברות משלמת. המספרים המדויקים חשובים פחות מהפער. קריאת SHA-256 עם salt לוקחת כאן בערך 4 מיקרו־שניות. קריאת Argon2id לוקחת בערך 640 אלפיות שנייה, וככה זה תוכנן. מכווננים את m, t וגם p על חומרה שדומה לפרודקשן, עד שהתחברות אחת עולה כמה מאות אלפיות שנייה.
verify. לא משווים מחרוזות עם ==.PasswordHasher, verify וגם check_needs_rehash.קריפטוגרפיה
הפרדה בין קידוד, הצפנה וגיבוב במעבדות עם Base64, SHA-256 ו-OpenSSL. למה Base64 אינו הגנה ולמה hash הוא חד־כיווני.
קריפטוגרפיה
מפתחות ציבוריים ופרטיים מאפס: מה חתימה מוכיחה, למה המפתח הפרטי נשאר סודי, ומעבדת Ed25519 שבה קובץ ששונה נכשל באימות.
קריפטוגרפיה
קריפטוגרפיה מאפס: הצפנה, מפתחות, hashes, חתימות, ולמה צופן קיסר מלמד רעיונות אבל לא מגן על כלום.