Wśród fundamentalnych protokołów kryptograficznych we współczesnym bezpieczeństwie sieciowym RFC 6238 (TOTP: Time-Based One-Time Password Algorithm) wyróżnia się matematyczną prostotą i elegancją. Nie wymaga interaktywnego uzgadniania sesji w sieci (handshake), scentralizowanej infrastruktury klucza publicznego (PKI) ani komunikacji w chmurze. Jedynie przy użyciu współdzielonego klucza tajnego Base32 oraz nieustannego upływu fizycznego czasu uniksowego (Unix epoch), klient i serwer obliczają identyczne, efemeryczne 6-cyfrowe tokeny uwierzytelniające co 30 sekund z pełną matematyczną pewnością.
W tym kompleksowym przewodniku omijamy zewnętrzne biblioteki typu „czarna skrzynka” (takie jak pyotp czy otplib), aby od podstaw zbudować kompletny potok kryptograficzny RFC 6238 w językach Python, JavaScript oraz Go, weryfikując każdą implementację względem oficjalnych wektorów testowych IETF.
01. Elegancja uwierzytelniania sterowanego czasem
Aby zrozumieć TOTP, musimy najpierw przeanalizować jego bezpośredniego poprzednika: RFC 4226 HOTP (HMAC-Based One-Time Password).
W HOTP klient i serwer dzielą licznik zdarzeń $C$. Za każdym razem, gdy użytkownik generuje token, licznik jest inkrementowany ($C = C + 1$), a token jest wyprowadzany z $\text{HMAC-SHA-1}(K, C)$. Choć matematycznie bezbłędny, HOTP cierpiał na operacyjną słabość: desynchronizację licznika. Jeśli użytkownik przypadkowo nacisnął przycisk na sprzętowym tokenie pięć razy w kieszeni, licznik klienta osiągał $C=6$, podczas gdy serwer uwierzytelniający pozostawał przy $C=1$, co skutkowało trwałym odrzucaniem prób logowania.
Opublikowany w maju 2011 roku przez N. M'Raihi, S. Machani, M. Pei i J. Rydella standard RFC 6238 rozwiązał problem desynchronizacji licznika, zastępując inkrementowaną liczbę całkowitą ujednoliconym czasem epoki uniksowej (Unix epoch time). Ponieważ czas fizyczny upływa jednakowo na całym świecie, zarówno klient, jak i serwer przesuwają swoje wewnętrzne liczniki jednocześnie, bez wysyłania ani jednego pakietu przez sieć.
02. 5 kryptograficznych kroków algorytmu TOTP
Przekształcenie 16-znakowego ziarna Base32 (np. JBSWY3DPEHPK3PXP) w dynamiczny, 6-cyfrowy kod zmienny (np. 284915) obejmuje pięć precyzyjnych faz kryptograficznych:
Klucze rejestracyjne są kodowane w formacie RFC 4648 Base32 przy użyciu alfabetu A-Z oraz cyfr 2-7 (z wyłączeniem znaków niejednoznacznych, takich jak 0, 1, 8, 9). Algorytm najpierw normalizuje ten ciąg, usuwając białe znaki i dodając dopełnienie (=), aby jego długość była wielokrotnością 8, a następnie dekoduje go do surowych bajtów binarnych klucza $K$.
Aktualny znacznik czasu Unix (liczba sekund od 1 stycznia 1970 UTC) jest mapowany na całkowitoliczbowy licznik $T$:
Ze standardowymi parametrami $T_0 = 0$ oraz czasem trwania kroku $X = 30$ sekund. Ta liczba całkowita $T$ jest następnie kodowana jako 8-bajtowa, 64-bitowa liczba całkowita bez znaku w formacie big-endian.
Używając surowych bajtów klucza $K$ jako klucza tajnego, obliczamy skrót HMAC-SHA1 na 8-bajtowym buforze czasu big-endian, uzyskując 20-bajtowy (160-bitowy) hasz uwierzytelniania wiadomości $HS$:
Aby przekształcić 20 surowych bajtów w liczbę całkowitą, standard RFC 4226 określa procedurę Dynamic Truncation:
- Wyodrębnienie przesunięcia (Offset): Pobierz ostatni bajt $HS[19]$ i zamaskuj go wartością
0x0F, aby wyodrębnić 4 najmłodsze bity, uzyskując przesunięcie od 0 do 15:Offset = HS[19] & 0x0F; - Wycięcie 4 bajtów: Pobierz 4 kolejne bajty zaczynając od Offset: $P = HS[Offset \dots Offset+3]$;
- Maskowanie bitu znaku: Zamaskuj najbardziej znaczący bit (MSB) wartością
0x7F, aby uniknąć problemów z liczbami ze znakiem:
BinaryCode = ((P[0] & 0x7F) << 24) | ((P[1] & 0xFF) << 16) | ((P[2] & 0xFF) << 8) | (P[3] & 0xFF)
Na koniec zredukuj BinaryCode modulo $10^6$ (1 000 000) dla standardowych 6-cyfrowych tokenów. Uzupełnij wynik wiodącymi zerami z lewej strony, aby uzyskać stałą długość ciągu 6 znaków:
03. Działający kod 1: Biblioteka standardowa Python 3
Ta kompletna implementacja wykorzystuje wbudowane moduły języka Python: base64, struct, hmac oraz hashlib, nie wymagając instalacji żadnych pakietów zewnętrznych przez 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. Działający kod 2: JavaScript i Crypto w Node.js
Implementacja w czystym JavaScript dla środowisk backendowych Node.js z wykorzystaniem natywnego modułu 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”));
Wykonywanie w przeglądarce po stronie klienta za pośrednictwem W3C Web Crypto API:
Dla nowoczesnych aplikacji internetowych wymagających 100% bezpieczeństwa offline (dokładnie taka architektura zasila 2FA-Tool), interfejs W3C Web Crypto API zapewnia asynchroniczne, akcelerowane sprzętowo prymitywy kryptograficzne bezpośrednio w piaskownicy przeglądarki bez potrzeby instalowania zewnętrznych pakietów 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. Działający kod 3: Produkcyjny Golang (Go)
Mikrousługi o wysokiej przepustowości korzystają ze sprawdzonej w bojach biblioteki standardowej 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. Weryfikacja wektorów testowych RFC 6238 Dodatek B
Aby udowodnić zgodność ze standardem, każda implementacja kryptograficzna musi zostać zweryfikowana względem oficjalnych wektorów testowych opublikowanych w IETF RFC 6238 Dodatek B.
Oficjalny zestaw testów wykorzystuje klucz w formacie ASCII "12345678901234567890" (20 bajtów), który w Base32 koduje się jako:
GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ (oceniany dla 8-cyfrowych tokenów wyjściowych z 30-sekundowymi krokami):
| Znacznik czasu Unix Epoch | Data i godzina UTC | Oficjalny oczekiwany kod RFC | Wynik implementacji |
|---|---|---|---|
| 59 | 1970-01-01 00:00:59 | 94287082 | ✓ ZALICZONE (Dokładne dopasowanie) |
| 1111111109 | 2005-03-18 01:58:29 | 07081804 | ✓ ZALICZONE (Dokładne dopasowanie) |
| 1111111111 | 2005-03-18 01:58:31 | 14050471 | ✓ ZALICZONE (Dokładne dopasowanie) |
| 1234567890 | 2009-02-13 23:31:30 | 89005924 | ✓ ZALICZONE (Dokładne dopasowanie) |
| 2000000000 | 2033-05-18 03:33:20 | 69279037 | ✓ ZALICZONE (Dokładne dopasowanie) |
07. Inżynieria produkcyjna: Dryf zegara, powtórzenia i limity żądań
Podczas wdrażania weryfikacji TOTP w systemach uwierzytelniania korporacyjnego o wysokiej dostępności należy wymusić trzy krytyczne wymagania bezpieczeństwa:
Ze względu na drobne niedokładności zegara w urządzeniach mobilnych serwery powinny oceniać okno [T - 1, T, T + 1] (sprawdzając poprzednie 30 s, bieżące 30 s i kolejne 30 s). Nigdy nie rozszerzaj okna poza $\pm 1$ krok, ponieważ każdy dodatkowy krok powiększa powierzchnię ataku powtórzeniowego (replay attack) o 30 sekund.
Token musi zostać natychmiast unieważniony po udanym użyciu. Zapisz Last_Verified_Time_Step w bazie Redis lub pamięci podręcznej. Jeśli klient spróbuje przesłać ten sam token w obrębie tego samego 30-sekundowego okna, odrzuć żądanie, aby zneutralizować ataki powtórzeniowe typu man-in-the-middle.
Ponieważ 6-cyfrowe kody obejmują zaledwie 1 000 000 permutacji, punkty końcowe bez limitu żądań mogą zostać szybko przełamane atakiem brute-force. Wymuś progresywne opóźnienia i blokady konta po 3 nieudanych próbach z rzędu. W kodzie wykonuj porównania ciągów znaków za pomocą funkcji porównujących w czasie stałym (np. crypto/subtle.ConstantTimeCompare w Go lub crypto.timingSafeEqual w Node.js), aby udaremnić analizę czasową kanałami bocznymi (side-channel timing attacks).
08. Często zadawane pytania (FAQ)
P1: Skoro SHA-1 jest kryptograficznie złamany, dlaczego TOTP nadal domyślnie używa HMAC-SHA1? ▼
Teoretyczne ataki kolizyjne przeciwko surowej funkcji SHA-1 nie naruszają konstrukcji HMAC (RFC 2104). W HMAC podstawowa funkcja skrótu jest zagnieżdżona z wewnętrznymi i zewnętrznymi kluczami tajnymi, co neutralizuje ataki oparte na kolizjach. Do dziś HMAC-SHA1 pozostaje bezpieczny dla 2FA. Ponadto RFC 6238 w pełni obsługuje HMAC-SHA256 i HMAC-SHA512, jeśli wymagany jest wyższy poziom pewności kryptograficznej.
P2: Czy strefy czasowe lub czas letni (DST) wpływają na synchronizację TOTP? ▼
Nie. Czas uniksowy (Unix epoch) jest zdefiniowany jako liczba sekund, które upłynęły od 1970-01-01 00:00:00 UTC. Jest on całkowicie niezależny od stref czasowych, sekund przestępnych i zmian czasu letniego. W dowolnym dokładnym momencie serwer w Tokio i smartfon w Kalifornii mają dokładnie taki sam całkowitoliczbowy znacznik czasu.
P3: Czym różni się 5-znakowe uwierzytelnianie Steam Guard od standardu RFC 6238? ▼
Steam Guard to autorski wariant oparty na RFC 6238. Używa dokładnie tego samego 30-sekundowego kroku czasowego i obliczeń HMAC-SHA1, ale w końcowej fazie redukcji mapuje wyodrębnioną liczbę całkowitą przez niestandardową 26-znakową tablicę alfanumeryczną (23456789BCDFGHJKMNPQRTVWXY), tworząc 5-znakowy ciąg tokena.
Wypróbuj generowanie kodów TOTP w 100% po stronie klienta na żywo
2FA-Tool implementuje ten dokładny potok kryptograficzny bezpośrednio w Twojej przeglądarce za pomocą W3C Web Crypto API. Zero transmisji do serwera, zero przechowywania tokenów, natychmiastowa walidacja.