Към съдържанието
wannacode

Криптографията, която не пишеш сам

Накратко: Криптографията се взима от стандартната библиотека, не се пише сама: криптографски случайни числа, сравнение за постоянно време и шифроване с проверка като AES-GCM.

Проблем

Кодът, който някой вече знае

Магазинът праща код от 6 цифри при „забравена парола“. Същият генератор прави и кодовете за отстъпка в бюлетина. Кодът изглежда случаен: 807592, 213904, 566120.

Една нощ някой сменя паролата на купувач и пазарува с картата му. Не е отгатвал милион кодове: пробвал е един. Знаел е кода, преди купувачът да отвори имейла си.

Генераторът е new Random(started), а started е секундата, в която магазинът е тръгнал.

Идеята

Random е формула, не случайност

Random е генератор на псевдослучайни числа: формула, която от едно число (семе) смята редица, която изглежда случайна. Едно и също семе дава една и съща редица. За игри, симулации и тестове това е полезно. За тайни е катастрофа: който знае семето или може да го отгатне, знае всяко следващо число.

Семето на магазина е секундата на старта. Атакуващият знае, че магазинът е рестартиран през нощта: 3600 възможности. Той има свой код за отстъпка от бюлетина. Пробва всяко семе, докато първият код съвпадне с неговия. Тогава следващият код на неговото копие е кодът на купувача.

За всичко, което трябва да е тайно и непредсказуемо (токени, кодове, ключове, salt, nonce), се ползва криптографски генератор. Той взима случайност от операционната система (събития от хардуера, шум) и е направен така, че от изхода му да не може да се изчисли следващият.

Виж го

Атакуващият пробва всяка секунда

static final SecureRandom RANDOM = new SecureRandom();
static String resetToken() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(bytes);
}
static String code() {
return String.format("%06d", RANDOM.nextInt(1_000_000));
}
Стъпка по стъпка, с думи
  1. Магазинът прави кодовете с Random, а семето е секундата на старта: 03:40:17. Първият код отива в бюлетина, и при атакуващия.
  2. Следващото число от същия генератор е кодът за смяна на паролата на купувач. Отива по имейл.
  3. Атакуващият знае, че магазинът е рестартиран през нощта. Пробва всяка секунда от 03:00: по един генератор за секунда.
  4. На опит 2418 първият код е същият като неговия код за отстъпка. Това е семето: 03:40:17.
  5. Следващото число на неговото копие е кодът на купувача. Сменя паролата и взима акаунта.
  6. Поправката: байтовете идват от криптографския генератор на операционната система. Семе за отгатване няма.
  7. 3600 опита или 3 милиона: нищо. Токенът е 32 случайни байта, а и кодът от 6 цифри вече не се предсказва.
static String code(Random random) {
return String.format("%06d", random.nextInt(1_000_000));
}
static String predictNext(String seen, long from,
int window) {
for (int i = 0; i < window; i++) {
Random guess = new Random(from + i);
if (code(guess).equals(seen)) {
System.out.println("Семе: опит " + (i + 1));
return code(guess);
}
}
return null;
}
long night = 1_793_070_000L; // 27.10.2026, 03:00 UTC
long started = night + 2_417; // магазинът е рестартиран в 03:40:17
Random random = new Random(started);
String discount = code(random);
String reset = code(random);
System.out.println("Код за отстъпка от бюлетина (у атакуващия): " + discount);
String predicted = predictNext(discount, night, 3_600);
System.out.println("Предсказан код за смяна на паролата: " + predicted);
System.out.println("Истинският код, пратен на купувача: " + reset);
Изход
Код за отстъпка от бюлетина (у атакуващия): 727726
Семе: опит 2418
Предсказан код за смяна на паролата: 807592
Истинският код, пратен на купувача: 807592

Кодовете на четирите езика са различни, защото генераторите са различни. Опитът е един и същ: 2418, защото семето е 03:40:17.

Идеята

Криптографският генератор на всеки език

static final SecureRandom RANDOM = new SecureRandom();
static String resetToken() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(bytes);
}
static String code() {
return String.format("%06d", RANDOM.nextInt(1_000_000));
}
String first = resetToken();
String second = resetToken();
System.out.println("Дължина на токена: " + first.length());
System.out.println("Само A–Z, a–z, 0–9, - и _: " + first.matches("[A-Za-z0-9_-]+"));
System.out.println("Два токена подред са различни: " + !first.equals(second));
System.out.println("Кодът е 6 цифри: " + code().matches("\\d{6}"));
Изход
Дължина на токена: 43
Само A–Z, a–z, 0–9, - и _: true
Два токена подред са различни: true
Кодът е 6 цифри: true

Токенът е 32 байта (256 бита), записани с Base64 за адреси: 43 знака, които могат да стоят в линк. Да се отгатне е колкото да се отгатне ключ на AES-256.

SecureRandom се създава веднъж и се ползва отново: първото пускане събира случайност от системата и може да е бавно. UUID.randomUUID() също ползва SecureRandom.

Пробвай

Предвиди изхода

Два генератора с едно и също семе. Какво ще отпечата?

Random a = new Random(42);
Random b = new Random(42);
System.out.println(a.nextInt(100) == b.nextInt(100));
System.out.println(a.nextInt(100) == b.nextInt(100));
Отговор

Пробвай

Попълни

Токен за смяна на паролата, от криптографския генератор:

static final SecureRandom RANDOM = new ();

Пробвай

Намери грешката

Токенът е дълъг и изглежда сигурен. Кой ред го прави предсказуем?

Избери реда с грешката.

Идеята

Сравнението, което не издава нищо

Когато магазинът проверява кода от имейла, той го сравнява с пазения. Обикновеното сравнение на низове спира при първата разлика. Колкото повече знаци от началото са верни, толкова по-дълго трае. По мрежата разликата е наносекунди, но с много опити и статистика се мери. Така атакуващият познава знак по знак: 10 опита за първия, 10 за втория…

Програмата долу не мери време: тя брои колко знака гледа сравнението, а то е същото, което прави времето различно.

// Какво прави equals отвътре: спира при първата разлика
static int comparedChars(String secret, String guess) {
int i = 0;
while (i < secret.length() && secret.charAt(i) == guess.charAt(i)) {
i++;
}
return Math.min(i + 1, secret.length());
}
String secret = "481937";
for (String guess : List.of("000000", "400000", "480000", "481000")) {
System.out.println(guess + ": сравнени знаци " + comparedChars(secret, guess));
}
byte[] expected = secret.getBytes(StandardCharsets.UTF_8);
for (String guess : List.of("481000", "481937")) {
byte[] given = guess.getBytes(StandardCharsets.UTF_8);
System.out.println(guess + ": MessageDigest.isEqual = " + MessageDigest.isEqual(expected, given));
}
Изход
000000: сравнени знаци 1
400000: сравнени знаци 2
480000: сравнени знаци 3
481000: сравнени знаци 4
481000: MessageDigest.isEqual = false
481937: MessageDigest.isEqual = true

Сравнението за постоянно време гледа всички байтове, каквото и да намери, и времето не зависи от това колко е познато. Всеки език го има готово: MessageDigest.isEqual. Ползва се за всичко тайно, което идва отвън: кодове, токени, подписи на JWT, HMAC.

Идеята

Шифроване: готов алгоритъм от готова библиотека

Магазинът пази IBAN-а на купувачите, които връщат стока. В базата той трябва да е шифрован, а при четене трябва да е сигурно, че никой не го е подменил. Двете ги прави шифроване с проверка (authenticated encryption). Стандартът е AES-GCM: шифрова и добавя таг от 16 байта. Ако се смени и един бит, тагът не съвпада и разшифроването спира с грешка.

AES-GCM е в стандартната библиотека:
static byte[] encrypt(SecretKey key, byte[] nonce, String text) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, nonce));
return cipher.doFinal(text.getBytes(StandardCharsets.UTF_8));
}
static String decrypt(SecretKey key, byte[] nonce, byte[] box) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, nonce));
return new String(cipher.doFinal(box), StandardCharsets.UTF_8);
}
KeyGenerator generator = KeyGenerator.getInstance("AES");
generator.init(256);
SecretKey key = generator.generateKey(); // в истинския магазин: от хранилището за тайни
byte[] nonce = new byte[12];
new SecureRandom().nextBytes(nonce); // нов за всяко съобщение
byte[] box = encrypt(key, nonce, "BG80 BNBG 9661 1020 3456 78");
System.out.println("Шифрован: " + box.length + " байта");
System.out.println("Разшифрован: " + decrypt(key, nonce, box));
box[5] ^= 1;
try {
decrypt(key, nonce, box);
} catch (AEADBadTagException e) {
System.out.println("Подменен един байт: проверката на тага не мина, отказвам");
}
Изход
Шифрован: 43 байта
Разшифрован: BG80 BNBG 9661 1020 3456 78
Подменен един байт: проверката на тага не мина, отказвам
Шифрованият IBAN е 27 байта текст и 16 байта таг. Ключът е 32 случайни байта, а в истинския магазин идва от хранилището за тайни, както в урока за тайните. Nonce-ът е 12 случайни байта, нови за всяко съобщение: два текста с един ключ и един nonce издават връзката между тях. Nonce-ът не е тайна и се пази до шифрования текст.

Виж го

Своята схема, разбита в няколко реда

Колега решава, че AES е „прекалено сложно“, и пише свое шифроване: всеки байт XOR с байт от тайна дума. Изглежда като шум. Но XOR се обръща сам, а всяка фактура започва с едни и същи думи:

static byte[] xor(byte[] data, byte[] key) {
byte[] out = new byte[data.length];
for (int i = 0; i < data.length; i++) {
out[i] = (byte) (data[i] ^ key[i % key.length]);
}
return out;
}
byte[] key = "SHOP2026".getBytes(StandardCharsets.UTF_8); // „тайният“ ключ на магазина
byte[] invoice = xor("ФАКТУРА 2026-0142, общо 249,90 €".getBytes(StandardCharsets.UTF_8), key);
byte[] iban = xor("BG80 BNBG 9661 1020 3456 78".getBytes(StandardCharsets.UTF_8), key);
// атакуващият: всяка фактура започва така
byte[] known = "ФАКТУРА ".getBytes(StandardCharsets.UTF_8);
byte[] found = xor(Arrays.copyOf(invoice, 8), Arrays.copyOf(known, 8));
System.out.println("Ключът е: " + new String(found, StandardCharsets.UTF_8));
System.out.println("Друго съобщение: " + new String(xor(iban, found), StandardCharsets.UTF_8));
Изход
Ключът е: 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 бита на код). Напиши discountCode с криптографския генератор, направи 1000 кода и провери, че всички са от азбуката и са различни:

Изход
Кодове: 1000
Всеки е 10 знака от азбуката: true
Различни: 1000

Още задачи

  • Изтичане. Пази до всеки код кога е направен и отказвай кодове, по-стари от 15 минути.
  • Опити. Заключвай кода след 5 грешни опита и брой опитите.
  • Без модул. Защо ALPHABET[randomByte % 32] е наред, а ALPHABET[randomByte % 30] не е? (Подсказка: 256 се дели на 32, но не и на 30.)

Тест

Провери се

1. Защо код от Random със семе е опасен за смяна на паролата, дори да е дълъг 32 знака?
2. Кое е криптографски генератор?
3. Защо токенът се сравнява с MessageDigest.isEqual / FixedTimeEquals / compare_digest / timingSafeEqual, а не с ==?
4. Магазинът „шифрова“ фактурите с XOR и тайна дума от 8 букви. Всяка фактура започва с „ФАКТУРА “. Колко трудно е да се намери думата?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·