Симметричное шифрование: AES и аутентификация
Хеш связывал данные, но ничего не прятал. Теперь прячем. Симметричное шифрование это самый старый и самый быстрый способ скрыть содержимое: у обеих сторон один общий ключ. Разберём AES, режимы работы, аутентифицированное шифрование (без которого шифровать опасно) и упрёмся в проблему, которую симметрия решить не может: как передать общий ключ по открытому каналу.
Идея
Симметричное шифрование устроено как замок с одним ключом: запираешь и отпираешь одним и тем же. Алиса и Боб заранее договорились о секретном ключе, Алиса шифрует им сообщение, Боб тем же ключом расшифровывает. Кто ключа не знает, видит только шум.
Главная рабочая лошадь это AES (Advanced Encryption Standard). Это блочный шифр: он перемешивает ровно 128 бит за раз, превращая блок открытого текста в блок шифротекста, и обратно тем же ключом. Ключ бывает 128, 192 или 256 бит, отсюда названия AES-128 и AES-256. Сам по себе AES это просто обратимая перестановка на 128-битных блоках, и из неё ещё надо собрать шифрование сообщения произвольной длины. Это делает режим работы, и выбор режима важнее выбора длины ключа: почти все провалы шифрования это не сломанный AES, а неправильный режим.
Внутри AES крутит блок через несколько одинаковых раундов: 10 для AES-128, 12 для AES-192, 14 для AES-256. Каждый раунд делает четыре шага. SubBytes меняет каждый байт по фиксированной таблице замен (S-box), это единственный нелинейный шаг и главная защита от линейного анализа. ShiftRows циклически сдвигает строки блока, разнося байты по позициям. MixColumns перемешивает байты внутри каждого столбца, чтобы один входной бит влиял на много выходных. AddRoundKey XOR-ит результат с раундовым ключом, выведенным из основного. Последний раунд пропускает MixColumns. Расшифровка прогоняет те же шаги в обратную сторону тем же ключом. Тебе не придётся писать это руками: AES берут из проверенной библиотеки. Понимать стоит другое, почему голый блочный шифр это ещё не шифрование сообщения.
Режимы: от опасного к правильному
ECB (Electronic Codebook) это «шифруем каждый блок независимо тем же ключом». Звучит наивно и провально: одинаковые блоки открытого текста дают одинаковые блоки шифротекста, и структура данных просвечивает сквозь шифр. Классическая картинка про зашифрованного в ECB пингвина, которого всё ещё видно, это памятник этому режиму. ECB не используют никогда.
CBC (Cipher Block Chaining) чинит это сцеплением: каждый блок перед шифрованием XOR-ится с предыдущим шифроблоком, а самый первый с публичным случайным IV. Одинаковые сообщения теперь шифруются по-разному. Но CBC требует дополнения до блока (padding), и неаккуратная проверка дополнения открывает padding oracle, целый класс атак, расшифровывающих сообщение без ключа. Серж Воденэ показал её в 2002: достаточно различимого ответа «дополнение верное или нет», и шифротекст вскрывается с конца побайтно. Сам по себе CBC ещё и пластичен: атакующий правит шифротекст так, что предсказуемо меняется открытый текст.
CTR (Counter) превращает блочный шифр в потоковый. Идея изящная: шифруем не данные, а счётчик nonce || 0, nonce || 1, nonce || 2, получаем псевдослучайный поток (гамму) и XOR-им его с открытым текстом. Дополнение не нужно, блоки независимы, всё параллелится. Цена: nonce с одним ключом нельзя повторять никогда. Повторил, и два сообщения зашифрованы одной гаммой, а XOR двух шифротекстов это XOR двух открытых текстов, секретность испарилась.
Покрути режимы на сетке ниже. Возьми рисунок с повторяющимися строками, включи ECB и посмотри, как силуэт остаётся читаемым, а CBC и CTR размывают его в шум.
3 из 8уникальных строк после шифрования: 3 из 8Преобразование здесь учебное и нарочно простое, это не настоящий AES, только способ увидеть главное: режим решает, утечёт структура или нет. На реальном AES разница та же.
Все три режима объединяет общая дыра: они дают секретность, но не дают целостности. Шифротекст можно молча подменить, и получатель расшифрует подделку, не заметив. В CTR это вообще тривиально: перевернул бит шифротекста, перевернулся ровно тот же бит открытого текста. Поэтому современное правило короткое: шифровать без аутентификации нельзя.
Гамма и потоковые шифры
CTR на самом деле переизобретает идею старше блочных шифров. Потоковый шифр не режет данные на блоки: он разворачивает из ключа и nonce длинную псевдослучайную ленту, её называют гаммой (по-английски keystream), и XOR-ит её с текстом байт за байтом. Расшифровка это тот же XOR той же гаммы: (m ⊕ g) ⊕ g = m. Никакого дополнения, длина шифротекста равна длине текста, шифровать можно хоть по байту по мере поступления. Вся стойкость упирается в два условия: гамма неотличима от случайной, и пара ключ плюс nonce не повторяется никогда, иначе две ленты совпадут со всеми последствиями из раздела про CTR.
Есть потоковые шифры, которые не построены из блочного ядра. Современный ориентир это ChaCha20: он раскладывает 256-битный ключ, счётчик и nonce в состояние из шестнадцати 32-битных слов и двадцать раундов месит его только сложениями, XOR и циклическими сдвигами (схема ARX). Таблиц замен нет вовсе, поэтому он работает за постоянное время на любом железе, без аппаратной поддержки и без утечек через кеш. Именно поэтому его любят там, где нет инструкций AES-NI, от телефонов до микроконтроллеров.
Связь с CTR теперь очевидна: CTR это способ превратить любой блочный шифр в потоковый. Гамма получается шифрованием счётчика, а сами данные через AES вообще не проходят. Собери это руками поверх голого блочного ядра, чтобы увидеть механику без магии:
# Cargo.toml
[dependencies]
aes = "0.8"
use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockEncrypt, KeyInit};
use aes::Aes128;
/// Ручной CTR поверх блочного ядра: шифруем счётчик, XOR-им гамму с данными.
fn ctr_xor(key: &[u8; 16], nonce: u64, data: &[u8]) -> Vec<u8> {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut out = Vec::with_capacity(data.len());
for (i, chunk) in data.chunks(16).enumerate() {
// Блок счётчика: 8 байт nonce и 8 байт номера блока.
let mut counter = [0u8; 16];
counter[..8].copy_from_slice(&nonce.to_be_bytes());
counter[8..].copy_from_slice(&(i as u64).to_be_bytes());
// Гамма этого блока: AES(key, counter). Сами данные через AES не идут.
let mut keystream = GenericArray::from(counter);
cipher.encrypt_block(&mut keystream);
// XOR гаммы с куском данных; хвост короче блока просто берёт меньше гаммы.
out.extend(chunk.iter().zip(keystream.iter()).map(|(d, g)| d ^ g));
}
out
}
fn main() {
let key = [7u8; 16];
let msg = b"attack at dawn, attack at dusk!";
let ct = ctr_xor(&key, 42, msg); // шифрование
let pt = ctr_xor(&key, 42, &ct); // расшифровка: тот же XOR той же гаммы
assert_eq!(&pt[..], &msg[..]);
}
Обрати внимание: ctr_xor одна на обе стороны, шифрование и расшифровка симметричны по построению. И правило про целостность для гаммы звучит громче некуда: один перевёрнутый бит шифротекста переворачивает ровно тот же бит открытого текста, поэтому в бою гамму никогда не выпускают без тега аутентификации.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.