מבין הפרוטוקולים הקריפטוגרפיים היסודיים באבטחת רשת מודרנית, תקן RFC 6238 (TOTP: Time-Based One-Time Password Algorithm) בולט בפשטותו המתמטית. הוא אינו דורש לחיצות ידיים אינטראקטיביות ברשת, ללא תשתית מפתח ציבורי (PKI) מרכזית, וללא פניות הלוך-חזור לענן. באמצעות מפתח סודי משותף בקידוד Base32 בלבד וצעידתו הקבועה קדימה של זמן יוניקס הפיזי, הלקוח והשרת מחשבים טוקני אימות בני 6 ספרות זהים וזמניים בכל 30 שניות בוודאות מתמטית של 100%.
במדריך מקיף זה, נעקוף ספריות צד שלישי של "קופסה שחורה" (כגון pyotp או otplib) כדי לבנות את הצינור הקריפטוגרפי המלא של RFC 6238 מאפס ב-Python, JavaScript ו-Go, תוך אימות כל מימוש מול וקטורי הבדיקה הרשמיים של ה-IETF.
01. האלגנטיות של אימות מונחה-זמן
כדי להבין את TOTP, תחילה עלינו להתחקות אחר קודמו: RFC 4226 HOTP (HMAC-Based One-Time Password).
ב-HOTP, הלקוח והשרת חולקים מונה אירועים $C$. בכל פעם שהמשתמש מפיק טוקן, המונה מקודם ($C = C + 1$), והטוקן נגזר מ-$\text{HMAC-SHA-1}(K, C)$. אף שהיה תקין מבחינה מתמטית, HOTP סבל מחולשה תפעולית: איבוד סנכרון של המונה (counter desynchronization). אם משתמש לחץ בטעות על הכפתור בדונגל החומרה שלו חמש פעמים בכיסו, מונה הלקוח הגיע ל-$C=6$ בעוד שרת האימות נותר ב-$C=1$, מה שהוביל לדחיות התחברות עקביות.
תקן RFC 6238, שפורסם במאי 2011 על ידי N. M'Raihi, S. Machani, M. Pei ו-J. Rydell, פתר את בעיית איבוד הסנכרון של המונה על ידי החלפת המספר השלם המתקדם בזמן יוניקס אחיד (Unix epoch time). מכיוון שהזמן הפיזי מתקדם באופן אחיד בכל רחבי העולם, הן הלקוח והן השרת מקדמים את המונים הפנימיים שלהם בו-זמנית מבלי לשלוח אף חבילת נתונים אחת ברשת.
02. חמשת השלבים המתמטיים-קריפטוגרפיים של TOTP
המרת מחרוזת Base32 בת 16 תווים (למשל, JBSWY3DPEHPK3PXP) לקוד מתחלף בן 6 ספרות (למשל, 284915) כוללת חמישה שלבים קריפטוגרפיים מדויקים:
מפתחות רישום מקודדים בתקן RFC 4648 Base32 באמצעות האלפבית A-Z ו-2-7 (ללא תווים דו-משמעיים כמו 0, 1, 8, 9). האלגוריתם מנרמל תחילה מחרוזת זו, מסיר רווחים ומוסיף ריפוד (=) כך שאורכה יהיה כפולה של 8, לפני פענוחה לבייטים של מפתח בינארי גולמי $K$.
חותמת הזמן הנוכחית של יוניקס (שניות שחלפו מאז 1 בינואר 1970 UTC) ממופה למונה שלם $T$:
עם פרמטרים סטנדרטיים $T_0 = 0$ ומשך שלב של $X = 30$ שניות. מספר שלם זה $T$ מקודד לאחר מכן כמספר שלם חיובי בן 8 בייטים בתצורת big-endian ללא סימן (64 סיביות).
באמצעות בייטי המפתח הגולמי $K$ כמפתח הסודי, אנו מחשבים את תמצית ה-HMAC-SHA1 על גבי מאגר הזמן בן 8 בייטים בתצורת big-endian, מה שמניב תמצית אימות הודעה $HS$ בת 20 בייטים (160 סיביות):
כדי להמיר 20 בייטים גולמיים למספר שלם, RFC 4226 מגדיר קיטום דינמי (Dynamic Truncation):
- חילוץ היסט (Offset): בחינת הבייט האחרון $HS[19]$ והפעלת מסכה של
0x0Fלחילוץ 4 הסיביות הנמוכות שלו, מה שנותן ערך היסט בין 0 ל-15:Offset = HS[19] & 0x0F; - חיתוך 4 בייטים: חילוץ 4 בייטים רצופים החל מההיסט: $P = HS[Offset \dots Offset+3]$;
- הסרת סיבית הסימן: מיסוך הסיבית המשמעותית ביותר (MSB) באמצעות
0x7Fלמניעת אי-בהירות של גלישת מספרים עם סימן:
BinaryCode = ((P[0] & 0x7F) << 24) | ((P[1] & 0xFF) << 16) | ((P[2] & 0xFF) << 8) | (P[3] & 0xFF)
לבסוף, מצמצמים את BinaryCode מודולו $10^6$ (1,000,000) עבור טוקנים סטנדרטיים בני 6 ספרות. מרפדים את התוצאה באפסים משמאל כדי להבטיח מחרוזת פלט עקבית בת 6 תווים:
03. קוד להרצה 1: הספרייה הסטנדרטית של Python 3
מימוש מלא זה עושה שימוש במודולים המובנים של Python: base64, struct, hmac ו-hashlib, ללא צורך בחבילות pip כלל:
import base64 import hashlib import hmac import struct import timedef generate_totp(secret_base32: str, timestamp: int = None, step: int = 30, digits: int = 6) -> str: """ Generate an RFC 6238 compliant TOTP token. :param secret_base32: Shared secret key in Base32 format (e.g., ‘JBSWY3DPEHPK3PXP’) :param timestamp: Unix epoch timestamp in seconds. Defaults to time.time() :param step: Time-step window in seconds (default: 30) :param digits: Number of output digits (default: 6) :return: Left-padded OTP string (e.g., ‘045812’) """ if timestamp is None: timestamp = int(time.time())
# 1. Normalize Base32 string and append '=' padding characters normalized_secret = secret_base32.replace(" ", "").upper() padding_needed = (8 - len(normalized_secret) % 8) % 8 normalized_secret += "=" * padding_needed key_bytes = base64.b32decode(normalized_secret) # 2. Compute 64-bit big-endian time-step counter T time_counter = timestamp // step time_bytes = struct.pack(">Q", time_counter) # 8 bytes big-endian unsigned long long # 3. Calculate HMAC-SHA1 digest (20 bytes) hmac_digest = hmac.new(key_bytes, time_bytes, hashlib.sha1).digest() # 4. Perform Dynamic Truncation offset = hmac_digest[-1] & 0x0F # Lower 4 bits of the 20th byte # Unpack 4 bytes at offset and clear MSB sign bit binary_code = struct.unpack(">I", hmac_digest[offset : offset + 4])[0] & 0x7FFFFFFF # 5. Modulo arithmetic and zero-fill formatting otp_code = binary_code % (10 ** digits) return str(otp_code).zfill(digits)
if name == “main”: test_secret = “JBSWY3DPEHPK3PXP” print(f”Generated TOTP Code: {generate_totp(test_secret)}”)
04. קוד להרצה 2: JavaScript ו-Node.js Crypto
מימוש JavaScript טהור עבור סביבות ריצה של Node.js בצד השרת באמצעות מודול crypto.createHmac המובנה:
const crypto = require('crypto');// RFC 4648 Base32 Decoder in Pure JavaScript function base32Decode(base32Str) { const alphabet = ‘ABCDEFGHIJKLMNOPQRSTUVWXYZ234567’; const cleaned = base32Str.replace(/=+$/, ”).toUpperCase().replace(/\s+/g, ”); let bits = 0, value = 0, index = 0; const output = new Uint8Array(Math.floor((cleaned.length * 5) / 8));
for (let i = 0; i < cleaned.length; i++) { const val = alphabet.indexOf(cleaned[i]); if (val === -1) continue; value = (value << 5) | val; bits += 5; if (bits >= 8) { output[index++] = (value >>> (bits - 8)) & 255; bits -= 8; } } return Buffer.from(output.buffer, 0, index); }
function generateTOTP(secretBase32, timestamp = Math.floor(Date.now() / 1000), step = 30, digits = 6) { const key = base32Decode(secretBase32); const counter = Math.floor(timestamp / step);
// Allocate 8-byte buffer in Big-Endian format const buffer = Buffer.alloc(8); buffer.writeBigUInt64BE(BigInt(counter), 0);
// Compute HMAC-SHA1 const hmac = crypto.createHmac(‘sha1’, key).update(buffer).digest();
// Dynamic Truncation const offset = hmac[hmac.length - 1] & 0x0f; const binary = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff);
const otp = binary % (10 ** digits); return otp.toString().padStart(digits, ‘0’); }
console.log(“Calculated TOTP:”, generateTOTP(“JBSWY3DPEHPK3PXP”));
הרצה בדפדפן בצד הלקוח באמצעות W3C Web Crypto API:
עבור יישומי אינטרנט מודרניים הדורשים אבטחה של 100% במצב לא מקוון (הארכיטקטורה המדויקת שמפעילה את 2FA-Tool), ה-W3C Web Crypto API מספק פרימיטיבים קריפטוגרפיים אסינכרוניים ומואצי-חומרה ישירות בארגז החול של הדפדפן ללא צורך בחבילות npm מצד שלישי:
async function generateBrowserTOTP(secretBase32, timestamp = Math.floor(Date.now() / 1000)) { const keyBytes = base32Decode(secretBase32); const counter = Math.floor(timestamp / 30);// 1. Import raw secret key into SubtleCrypto const cryptoKey = await window.crypto.subtle.importKey( “raw”, keyBytes, { name: “HMAC”, hash: { name: “SHA-1” } }, false, [“sign”] );
// 2. Encode 8-byte Big-Endian counter buffer const buffer = new ArrayBuffer(8); const view = new DataView(buffer); view.setBigUint64(0, BigInt(counter), false); // false = Big-Endian
// 3. Perform cryptographic HMAC sign const signature = await window.crypto.subtle.sign(“HMAC”, cryptoKey, buffer); const hmacResult = new Uint8Array(signature);
// 4. Dynamic Truncation const offset = hmacResult[hmacResult.length - 1] & 0x0f; const binary = ((hmacResult[offset] & 0x7f) << 24) | ((hmacResult[offset + 1] & 0xff) << 16) | ((hmacResult[offset + 2] & 0xff) << 8) | (hmacResult[offset + 3] & 0xff);
const otp = binary % 1000000; return otp.toString().padStart(6, ‘0’); }
05. קוד להרצה 3: Golang (Go) לסביבת ייצור
מיקרו-שירותים בעלי עומס גבוה נהנים מהספרייה הסטנדרטית של Go שעמדה במבחני שטח:
package mainimport ( “crypto/hmac” “crypto/sha1” “encoding/base32” “encoding/binary” “fmt” “math” “strings” “time” )
// GenerateTOTP computes standard RFC 6238 time-based tokens func GenerateTOTP(secretBase32 string, timestamp int64, step int64, digits int) (string, error) { // 1. Sanitize Base32 seed and decode cleaned := strings.ToUpper(strings.ReplaceAll(secretBase32, ” ”, "")) key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(cleaned) if err != nil { key, err = base32.StdEncoding.DecodeString(cleaned) if err != nil { return "", err } }
// 2. Encode 64-bit Big-Endian time-step counter counter := uint64(timestamp / step) buf := make([]byte, 8) binary.BigEndian.PutUint64(buf, counter) // 3. Compute HMAC-SHA1 mac := hmac.New(sha1.New, key) mac.Write(buf) hash := mac.Sum(nil) // 4. Dynamic Truncation offset := hash[len(hash)-1] & 0x0f binaryCode := binary.BigEndian.Uint32(hash[offset:offset+4]) & 0x7fffffff // 5. Modulo arithmetic mod := uint32(math.Pow10(digits)) otp := binaryCode % mod format := fmt.Sprintf("%%0%dd", digits) return fmt.Sprintf(format, otp), nil}
func main() { code, err := GenerateTOTP(“JBSWY3DPEHPK3PXP”, time.Now().Unix(), 30, 6) if err != nil { panic(err) } fmt.Printf(“Golang Computed TOTP: %s\n”, code) }
06. אימות וקטורי בדיקה מנספח B של RFC 6238
כדי להוכיח עמידה בתקן, יש לאמת כל מימוש קריפטוגרפי מול וקטורי הבדיקה הרשמיים שפורסמו בנספח B של IETF RFC 6238.
חבילת הבדיקות הרשמית משתמשת בסוד ה-ASCII הבא: "12345678901234567890" (20 בייטים), אשר מקודד ב-Base32 כ:
GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ (מוערך עבור פלט של 8 ספרות עם צעדים של 30 שניות):
| חותמת זמן יוניקס (Epoch) | תאריך ושעה ב-UTC | קוד צפוי רשמי לפי ה-RFC | תוצאת המימוש |
|---|---|---|---|
| 59 | 1970-01-01 00:00:59 | 94287082 | ✓ PASSED (התאמה מדויקת) |
| 1111111109 | 2005-03-18 01:58:29 | 07081804 | ✓ PASSED (התאמה מדויקת) |
| 1111111111 | 2005-03-18 01:58:31 | 14050471 | ✓ PASSED (התאמה מדויקת) |
| 1234567890 | 2009-02-13 23:31:30 | 89005924 | ✓ PASSED (התאמה מדויקת) |
| 2000000000 | 2033-05-18 03:33:20 | 69279037 | ✓ PASSED (התאמה מדויקת) |
07. הנדסה בסביבת ייצור: סטיית שעון, מתקפות שידור חוזר ומגבלות קצב
בעת הטמעת אימות TOTP במערכות אימות ארגוניות בעלות זמינות גבוהה, יש לאכוף שלוש דרישות אבטחה קריטיות:
בשל אי-דיוקים קלים בשעוני מכשירים ניידים, שרתים צריכים לבדוק את החלון [T - 1, T, T + 1] (בדיקת 30 השניות הקודמות, 30 השניות הנוכחיות ו-30 השניות הבאות). לעולם אין להרחיב את החלון מעבר ל-$\pm 1$ צעדים, שכן כל צעד נוסף מרחיב את שטח הפנים למתקפת שידור חוזר (Replay Attack) ב-30 שניות נוספות.
יש לפסול טוקן מיד עם השימוש המוצלח בו. שמרו את Last_Verified_Time_Step ב-Redis או בזיכרון מטמון. אם לקוח מנסה לשלוח את אותו הטוקן בתוך אותו חלון של 30 שניות, דחו את הבקשה כדי לנטרל מתקפות שידור חוזר של אדם-באמצע (MitM).
מכיוון שקודים בני 6 ספרות מקיפים 1,000,000 צירופים בלבד, נקודות קצה שאינן מוגבלות ניתנות לפריצה מהירה בכוח גס (Brute-Force). אכפו השהיות מדורגות וחסימת חשבון לאחר 3 כשלונות רצופים. בקוד, בצעו השוואות באמצעות פונקציות שוויון בזמן קבוע (למשל, crypto/subtle.ConstantTimeCompare ב-Go או crypto.timingSafeEqual ב-Node.js) כדי לסכל ניתוחי תזמון בערוץ צדדי (Side-Channel Timing Attacks).
08. שאלות נפוצות (FAQ)
ש1: מכיוון ש-SHA-1 נחשב לפרוץ מבחינה קריפטוגרפית, מדוע ברירת המחדל של TOTP עדיין משתמשת ב-HMAC-SHA1? ▼
התקפות התנגשות (Collision Attacks) תיאורטיות נגד SHA-1 רגיל אינן פוגעות במבנה של HMAC (תקן RFC 2104). ב-HMAC, פונקציית הגיבוב הבסיסית מקוננת בתוך מפתחות סודיים פנימיים וחיצוניים, מה שמנטרל התקפות מבוססות התנגשות. עד היום, HMAC-SHA1 נותר מאובטח עבור 2FA. יתר על כן, RFC 6238 תומך באופן מלא ב-HMAC-SHA256 וב-HMAC-SHA512 במידה ונדרשת רמת ביטחון גבוהה יותר.
ש2: האם אזורי זמן או שעון קיץ (DST) משפיעים על סנכרון TOTP? ▼
לא. זמן Epoch של יוניקס מוגדר כשניות שחלפו מאז 1970-01-01 00:00:00 UTC. הוא אדיש לחלוטין לאזורי זמן, שניות עיבור ומעברי שעון קיץ. בכל רגע נתון, לשרת בטוקיו ולסמארטפון בקליפורניה יש חותמת זמן של מספר שלם זהה לחלוטין.
ש3: במה שונה מנגנון האימות בן 5 האותיות של Steam Guard מתקן RFC 6238? ▼
Steam Guard הוא וריאנט קנייני המבוסס על RFC 6238. הוא משתמש בדיוק באותו מרווח זמן של 30 שניות ובאותו חישוב HMAC-SHA1, אך בשלב הצמצום הסופי, הוא ממפה את המספר השלם שחולץ דרך מערך תווים אלפאנומרי מותאם אישית בן 26 תווים (23456789BCDFGHJKMNPQRTVWXY) כדי להפיק מחרוזת בת 5 תווים.
התנסו בזמן אמת בהפקת קודי TOTP מלאה בצד הלקוח
2FA-Tool מיישם את אותו צינור קריפטוגרפי ישירות בתוך הדפדפן שלכם באמצעות ה-W3C Web Crypto API. אפס שידור לשרתים, אפס אחסון טוקנים, אימות מיידי.