Криптографията, която не пишеш сам
Накратко: Криптографията не се пише сама: в Python модулът secrets дава случайни числа, hmac.compare_digest сравнява за постоянно време, а за AES се ползва пакетът cryptography.
Проблем
Кодът, който някой вече знае
Магазинът праща код от 6 цифри при „забравена парола“. Същият генератор прави и кодовете за отстъпка в бюлетина. Кодът изглежда случаен: 807592, 213904, 566120.
Една нощ някой сменя паролата на купувач и пазарува с картата му. Не е отгатвал милион кодове: пробвал е един. Знаел е кода, преди купувачът да отвори имейла си.
Генераторът е random.Random(started), а started е секундата, в която магазинът е тръгнал.
Идеята
Random е формула, не случайност
Random е генератор на псевдослучайни числа: формула, която от едно число (семе) смята редица, която изглежда случайна. Едно и също семе дава една и съща редица. За игри, симулации и тестове това е полезно. За тайни е катастрофа: който знае семето или може да го отгатне, знае всяко следващо число.
Семето на магазина е секундата на старта. Атакуващият знае, че магазинът е рестартиран през нощта: 3600 възможности. Той има свой код за отстъпка от бюлетина. Пробва всяко семе, докато първият код съвпадне с неговия. Тогава следващият код на неговото копие е кодът на купувача.
За всичко, което трябва да е тайно и непредсказуемо (токени, кодове, ключове, salt, nonce), се ползва криптографски генератор. Той взима случайност от операционната система (събития от хардуера, шум) и е направен така, че от изхода му да не може да се изчисли следващият.
Виж го
Атакуващият пробва всяка секунда
def reset_token(): return secrets.token_urlsafe(32)
def code(): return f"{secrets.randbelow(1_000_000):06d}"def code(rng): return f"{rng.randrange(1_000_000):06d}"
def predict_next(seen, start, window): for i in range(window): guess = random.Random(start + i) if code(guess) == seen: print(f"Семе: опит {i + 1}") return code(guess) return Nonedef reset_token(): return secrets.token_urlsafe(32)
def code(): return f"{secrets.randbelow(1_000_000):06d}"Стъпка по стъпка, с думи
- Магазинът прави кодовете с Random, а семето е секундата на старта: 03:40:17. Първият код отива в бюлетина, и при атакуващия.
- Следващото число от същия генератор е кодът за смяна на паролата на купувач. Отива по имейл.
- Атакуващият знае, че магазинът е рестартиран през нощта. Пробва всяка секунда от 03:00: по един генератор за секунда.
- На опит 2418 първият код е същият като неговия код за отстъпка. Това е семето: 03:40:17.
- Следващото число на неговото копие е кодът на купувача. Сменя паролата и взима акаунта.
- Поправката: байтовете идват от криптографския генератор на операционната система. Семе за отгатване няма.
- 3600 опита или 3 милиона: нищо. Токенът е 32 случайни байта, а и кодът от 6 цифри вече не се предсказва.
def code(rng): return f"{rng.randrange(1_000_000):06d}"
def predict_next(seen, start, window): for i in range(window): guess = random.Random(start + i) if code(guess) == seen: print(f"Семе: опит {i + 1}") return code(guess) return Nonenight = 1_793_070_000 # 27.10.2026, 03:00 UTCstarted = night + 2_417 # магазинът е рестартиран в 03:40:17rng = random.Random(started)discount = code(rng)reset = code(rng)print(f"Код за отстъпка от бюлетина (у атакуващия): {discount}")predicted = predict_next(discount, night, 3_600)print(f"Предсказан код за смяна на паролата: {predicted}")print(f"Истинският код, пратен на купувача: {reset}")Код за отстъпка от бюлетина (у атакуващия): 573765Семе: опит 2418Предсказан код за смяна на паролата: 271558Истинският код, пратен на купувача: 271558Кодовете на четирите езика са различни, защото генераторите са различни. Опитът е един и същ: 2418, защото семето е 03:40:17.
Идеята
Криптографският генератор на всеки език
def reset_token(): return secrets.token_urlsafe(32)
def code(): return f"{secrets.randbelow(1_000_000):06d}"first = reset_token()second = reset_token()print(f"Дължина на токена: {len(first)}")print(f"Само A–Z, a–z, 0–9, - и _: {re.fullmatch(r'[A-Za-z0-9_-]+', first) is not None}")print(f"Два токена подред са различни: {first != second}")print(f"Кодът е 6 цифри: {re.fullmatch(r'[0-9]{6}', code()) is not None}")Дължина на токена: 43Само A–Z, a–z, 0–9, - и _: TrueДва токена подред са различни: TrueКодът е 6 цифри: TrueТокенът е 32 байта (256 бита), записани с Base64 за адреси: 43 знака, които могат да стоят в линк. Да се отгатне е колкото да се отгатне ключ на AES-256.
Модулътsecrets е направен точно за това: token_urlsafe, token_hex, token_bytes, randbelow, choice. Модулът random е за симулации; документацията му го казва с червено.Пробвай
Предвиди изхода
Два генератора с едно и също семе. Какво ще отпечата?
a = random.Random(42)b = random.Random(42)print(a.randrange(100) == b.randrange(100))print(a.randrange(100) == b.randrange(100))Едно и също семе дава една и съща редица, число по число. Затова и вторите числа са равни. Точно на това стъпва атакуващият: неговото копие върви заедно с генератора на магазина.
Пробвай
Попълни
Токен за смяна на паролата, от криптографския генератор:
token = secrets.(32)secrets.token_urlsafe(32) дава 32 случайни байта, записани с Base64 за адреси.Пробвай
Намери грешката
Токенът е дълъг и изглежда сигурен. Кой ред го прави предсказуем?
Избери реда с грешката.
random.choice е от модула random: Mersenne Twister, чието състояние се възстановява от достатъчно много изходи. Правилното е secrets.choice или направо secrets.token_urlsafe(32).Идеята
Сравнението, което не издава нищо
Когато магазинът проверява кода от имейла, той го сравнява с пазения. Обикновеното сравнение на низове спира при първата разлика. Колкото повече знаци от началото са верни, толкова по-дълго трае. По мрежата разликата е наносекунди, но с много опити и статистика се мери. Така атакуващият познава знак по знак: 10 опита за първия, 10 за втория…
Програмата долу не мери време: тя брои колко знака гледа сравнението, а то е същото, което прави времето различно.
# Какво прави == отвътре: спира при първата разликаdef compared_chars(secret, guess): i = 0 while i < len(secret) and secret[i] == guess[i]: i += 1 return min(i + 1, len(secret))secret = "481937"for guess in ["000000", "400000", "480000", "481000"]: print(f"{guess}: сравнени знаци {compared_chars(secret, guess)}")for guess in ["481000", "481937"]: print(f"{guess}: compare_digest = {hmac.compare_digest(secret, guess)}")000000: сравнени знаци 1400000: сравнени знаци 2480000: сравнени знаци 3481000: сравнени знаци 4481000: compare_digest = False481937: compare_digest = TrueСравнението за постоянно време гледа всички байтове, каквото и да намери, и времето не зависи от това колко е познато. Всеки език го има готово: hmac.compare_digest. Ползва се за всичко тайно, което идва отвън: кодове, токени, подписи на JWT, HMAC.
Идеята
Шифроване: готов алгоритъм от готова библиотека
Магазинът пази IBAN-а на купувачите, които връщат стока. В базата той трябва да е шифрован, а при четене трябва да е сигурно, че никой не го е подменил. Двете ги прави шифроване с проверка (authenticated encryption). Стандартът е AES-GCM: шифрова и добавя таг от 16 байта. Ако се смени и един бит, тагът не съвпада и разшифроването спира с грешка.
В стандартната библиотека на Python няма AES. Имаhashlib, hmac и secrets, но не и шифроване. Хората ползват пакета cryptography. Ето как изглежда с него; тук не се пуска, защото проверката на кода на сайта е само със стандартната библиотека:# pip install cryptography: не е в стандартната библиотекаfrom cryptography.hazmat.primitives.ciphers.aead import AESGCM
def encrypt(key, nonce, text): return AESGCM(key).encrypt(nonce, text.encode(), None)
def decrypt(key, nonce, box): return AESGCM(key).decrypt(nonce, box, None).decode() # InvalidTag при подмянаСамо със стандартната библиотека можеш да пазиш от подмяна с HMAC, но не и да скриеш текста:
def seal(key, text): data = text.encode() tag = hmac.new(key, data, hashlib.sha256).digest() return data + tag
def unseal(key, box): data, tag = box[:-32], box[-32:] expected = hmac.new(key, data, hashlib.sha256).digest() if not hmac.compare_digest(tag, expected): raise ValueError("tag") return data.decode()
key = secrets.token_bytes(32) # в истинския магазин: от хранилището за тайниbox = seal(key, "BG80 BNBG 9661 1020 3456 78")print(f"Запечатан: {len(box)} байта")print(f"Отворен: {unseal(key, box)}")tampered = bytearray(box)tampered[5] ^= 1try: unseal(key, bytes(tampered))except ValueError: print("Подменен един байт: проверката на тага не мина, отказвам")Запечатан: 59 байтаОтворен: BG80 BNBG 9661 1020 3456 78Подменен един байт: проверката на тага не мина, отказвамIBAN-ът тук е в открит вид: 27 байта текст и 32 байта таг. Това е цялост без тайна. За тайна трябва AES-GCM от cryptography. Ключът в истинския магазин идва от хранилището за тайни, както в урока за тайните.
Виж го
Своята схема, разбита в няколко реда
Колега решава, че AES е „прекалено сложно“, и пише свое шифроване: всеки байт XOR с байт от тайна дума. Изглежда като шум. Но XOR се обръща сам, а всяка фактура започва с едни и същи думи:
def xor(data, key): return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))key = "SHOP2026".encode() # „тайният“ ключ на магазинаinvoice = xor("ФАКТУРА 2026-0142, общо 249,90 €".encode(), key)iban = xor("BG80 BNBG 9661 1020 3456 78".encode(), key)# атакуващият: всяка фактура започва такаknown = "ФАКТУРА ".encode()found = xor(invoice[:8], known[:8])print(f"Ключът е: {found.decode()}")print(f"Друго съобщение: {xor(iban, found).decode()}")Ключът е: SHOP2026Друго съобщение: BG80 BNBG 9661 1020 3456 78Тайната дума излиза от първите 8 байта на една фактура, а с нея се чете всичко, шифровано някога. Не са нужни математика и милиарди опити, само известно начало на текста.
Това е правилото на урока: не измисляй своя криптография. Не своя алгоритъм, не своя схема около готов алгоритъм, не свой генератор. Хората, които правят криптография, публикуват схемите си и чакат години други да ги чупят. Ползваш тяхната работа през библиотека.
По-точно, за любопитните
Кое за какво.
- Пароли: бавен hash със salt (bcrypt, argon2id), урокът за паролите. Никога шифроване: паролата не трябва да може да се разчете обратно.
- Подпис на съобщение с общ ключ: HMAC-SHA256, както в JWT.
- Шифроване на данни: AES-GCM (или ChaCha20-Poly1305) с ключ от хранилище за тайни.
- Токени и кодове: криптографският генератор.
- Hash за цялост на файл: SHA-256 или SHA-512, както в урока за зависимостите. MD5 и SHA-1 са счупени за това.
Библиотеки на по-високо ниво. AES-GCM пак иска да не повториш nonce и да пазиш ключа. Библиотеките като libsodium (NaCl), Google Tink и Fernet в cryptography на Python скриват и тези решения: имаш encrypt(key, text) и decrypt(key, box), без режими и nonce.
Сравнение за постоянно време отвътре. Сравнява всички байтове и събира разликите с XOR и OR: diff |= a[i] ^ b[i], а накрая проверява diff == 0. Няма ранно излизане. Не го пишеш сам: компилаторът може да го „оптимизира“; готовите функции са направени така, че да не стане.
Кодът от 6 цифри. Дори от криптографски генератор, 6 цифри са милион възможности. Затова кодът изтича след минути и се заключва след няколко грешни опита. Токенът в линк е 32 байта и тези ограничения не са нужни за отгатването, а за изтичане (например в лог).
UUID. UUID.randomUUID() в Java, uuid.uuid4() в Python и crypto.randomUUID() в Node са от криптографски генератор. Guid.NewGuid() в .NET на практика също, но документацията не го обещава, затова за тайни ползвай RandomNumberGenerator. Самият UUID е 122 случайни бита: става за id, но токенът от 32 байта е по-ясен.
Упражнение
Кодове за отстъпка
Реши задачата в редактора си.
Магазинът иска кодове за отстъпка от 10 знака, които се четат лесно: само главни латински букви и цифри, без 0, O, 1 и I, които се бъркат. Азбуката е ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (32 знака, 50 бита на код). Напиши discount_code с криптографския генератор, направи 1000 кода и провери, че всички са от азбуката и са различни:
Кодове: 1000Всеки е 10 знака от азбуката: TrueРазлични: 1000secrets.choice(азбука) избира един знак.Още задачи
- Изтичане. Пази до всеки код кога е направен и отказвай кодове, по-стари от 15 минути.
- Опити. Заключвай кода след 5 грешни опита и брой опитите.
- Без модул. Защо
ALPHABET[randomByte % 32]е наред, аALPHABET[randomByte % 30]не е? (Подсказка: 256 се дели на 32, но не и на 30.)
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Ламбди · Пароли · JWT и капаните му · Тайни в кода и в git
- Задачи по темата: Сигурност за програмисти