Моим первым хобби-проектом по изучению Rust было внедрение криптографических хеш-алгоритмов [MD5, SHA-2, SHA-3]. Последний, SHA-3, был самой сложной реализацией.
Это произошло из-за а) существенных отличий в конструкции алгоритма от более знакомых предшественников MD5 и SHA и б) меньшего количества и менее интуитивно понятных эталонных реализаций и объяснений алгоритмов. В этой статье я кратко обобщу некоторые высокоуровневые различия между SHA-3 и более ранними алгоритмами, а затем пройдусь по его реализации.
Высокоуровневая структура MD5, SHA-1 и SHA-2 довольно похожа; эти алгоритмы основаны на Конструкции Меркле-Дамгарда. Это особая методология последовательного связывания фрагментов входного сообщения вместе с помощью функции одностороннего сжатия (которая может состоять из нескольких различных операций). Они также часто строятся из блочных шифров.
С десяти тысяч футов эти алгоритмы выглядят так:
MDHash(M: message): bytes P = padding (M) // extends length to a multiple of N bits bytes S = initial working state default values for chunk in P: S += compression_function(S, chunk) return S
Функция сжатия выполняет ряд нелинейных побитовых операций смешивания состояний, постоянных значений и входных битов сообщения. Вот пример из части функции сжатия SHA-2:
// See https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf # 6.2.2 while indx < 64 { /* * From https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf # 4.1.2 * * The functions Σ0, Σ1, Ch(x, y, z) and Maj(x, y, z) */ let s0 = state.a.rotate_right(2) ^ state.a.rotate_right(13) ^ state.a.rotate_right(22); let s1 = state.e.rotate_right(6) ^ state.e.rotate_right(11) ^ state.e.rotate_right(25); let ch = (state.e & state.f) ^ ((!state.e) & state.g); let maj = (state.a & state.b) ^ (state.a & state.c) ^ (state.b & state.c); // See https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf // # 6.2.2 section 3 state.rotate( state.h.wrapping_add(s1) .wrapping_add(ch) .wrapping_add(K[indx]) .wrapping_add(w[indx]), s0.wrapping_add(maj) ); indx += 1; }
Этот метод построения хэшей страдает множеством уязвимостей, и подход, используемый в SHA-3, отличается.
SHA-3 был выпущен NIST в 2015 году и основан на алгоритме хеширования под названием Keccak, представленном в 2012 году. Ниже я буду использовать эти термины как синонимы. Вместо того, чтобы полагаться на процесс Меркла-Дамгарда, SHA-3 / Keccak использует технику, называемую конструкцией губки.
С десяти тысяч футов это выглядит примерно так:
SHA-3(M: message, N: digest length): bytes P = padding (M) // extends length to a multiple of N bits bytes S = 0x0 // state // "absorb" for chunk in P: S[index] ^= chunk permute(S) // mix entire state bytes output = [] // "squeeze" while output length < N: output += S[0...M] permute(S) // mix entire state return output
Я не буду пытаться охватить здесь многие сложные свойства этого подхода. Пара пунктов высокого уровня:
- Длина массива состояний в битах не соответствует 1:1 предполагаемому размеру или содержимому вывода дайджеста. Вместо этого фрагменты префикса итеративно извлекаются из состояния.
- Во время вычисления дайджеста состояние дополнительно переставляется по достижении конца каждого фрагмента состояния. Этот процесс повторяется до тех пор, пока выходной буфер не достигнет заданной длины.
Ниже я подробно расскажу о том, как работает SHA-3.
Прокладка
Заполнение в SHA-3 является простым и напоминает подходы к заполнению в более ранних алгоритмах хеширования. Цель заполнения — увеличить длину входного сообщения до кратного скорости, значения, определенного в этой таблице как r
, что позволяет легко разбивать на фрагменты.
0x06
должен быть добавлен сразу после сообщения, даже если сообщение уже кратно r
. Ноль или более нулевых байтов добавляются до тех пор, пока длина массива не станет на 1 байт меньше кратного r
, а затем 0x80
объединяется как последний байт.
В особом случае, когда длина входного сообщения равна r — 1
, эти два разделителя объединяются вместе как 0x06 | 0x80
в одном байте.
Вы можете увидеть эту реализацию здесь, в Rust.
fn pad (message: &mut Vec<u8>, rate: usize) { let rate_bytes = rate / 8; if message.len() == rate_bytes - 1 { // Special case. 0x06 and 0x80 must always be present. // If the message length is 1 byte less than a // multiple of the rate, append these two values OR'd together // so the last byte is 0x86 message.push(0x06 | 0x80); } else { // Messages are padded to something like // [... 0x06, 0x0, 0x0 ..., 0x80] message.push(0x06); while (message.len() % rate_bytes) != rate_bytes - 1 { message.push(0x0); } message.push(0x80); } }
Однако одно интересное замечание заключается в том, что спецификация SHA-3 вносит небольшое изменение по сравнению со спецификацией алгоритма Кекчака — в определении заполнения. Алгоритм Keccak добавляет 0x01
сразу после сообщения:
Спецификация SHA-3 изменила начальные 0x1
на 0x6
. Это означает, что, несмотря на то, что SHA-3 основан на Keccak, хэш сообщения Keccak приведет к другому результату дайджеста, чем SHA-3. Разница здесь всего в трех битах в заполнении, но значение дайджеста будет совершенно другим из-за лавинного эффекта.
Состояние, фрагментация и поглощение
Спецификация алгоритма определяет массив состояний как трехмерный массив битов, в зависимости от конфигурации и значения параметра w
, в диапазоне от 25 до 1600 полных битов.
Это моделирование состояния не было особенно полезным для меня; было намного проще думать о состоянии как о массиве 5x5 64-битных целых чисел (u64
), который захватывал максимальный размер состояния 1600 бит. В отличие от других алгоритмов хеширования, состояние инициализируется не набором магических чисел, а просто нулем.
Входное сообщение разбивается на 512-битные блоки, что эквивалентно 8 u64
. Каждое из этих целых чисел затем «поглощается» состоянием, что означает XOR
-помещение входящего целого числа в соответствующее место в состоянии.
// Each 512-bit chunk is further broken into 8 64-bit words; // each is independently absorbed into the state array for chunk in block.chunks(8) { // Convert message byte chunks into a little-endian u64 integer let (b1, b2, b3, b4, b5, b6, b7, b8) = (chunk[0] as u64, chunk[1] as u64, chunk[2] as u64, chunk[3] as u64, chunk[4] as u64, chunk[5] as u64, chunk[6] as u64, chunk[7] as u64); let word = (b8 << 56) | (b7 << 48) | (b6 << 40) | (b5 << 32) | (b4 << 24) | (b3 << 16) | (b2 << 8) | b1; state.absorb(indx, word); indx += 1; } fn absorb (&mut self, indx: usize, word: u64) { // Some transformation from single index to element in this 2D state array self.state[indx / 5][indx % 5] ^= word; }
После поглощения каждого блока из восьми u64
фрагментов состояние меняется.
Перестановка
Шаг перестановки — это 24 раунда выполнения пяти функций над данными состояния. Каждый раунд приводит к необратимой промежуточной перестановке битов в состоянии.
/** * From https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.202.pdf # 3.3 * * Given a state array A and a round index ir, the round function Rnd * is the transformation that results from applying the * step mappings θ, ρ, π, χ, and ι, in that order, i.e.: * Rnd(A, ir) = ι(χ(π(ρ(θ(A)))), ir). * * See also https://keccak.team/keccak_specs_summary.html for pseudo-code * descriptions of this permutation, its 24 rounds, and * the underlying functions. */ fn permute (&mut self) { for i in 0..24 { self.theta(); self.rho_and_pi(); self.chi(); self.iota(i); } }
Каждая из этих функций выполняет мутацию на некоторой плоскости массива состояний. Например, здесь проиллюстрирован эффект rho
, визуализирующий «лист» или столбец полных целых чисел в состоянии с использованием 3D-моделирования состояния из предыдущего:
Эта конкретная функция выполняет целочисленное вращение для каждого u64
в массиве состояний, где смещения вращения определены в Сводке Keccak. Эта конкретная функция связана с pi
, которая меняет расположение каждого u64
в массиве состояний. Это немного похоже на вращение кубика Рубика.
Я постарался описать и процитировать конкретные детали реализации каждой из этих функций (theta
, rho
, pi
, chi
, iota
) в коде для тех, кто заинтересован.
сжатие
Последним шагом является извлечение содержимого массива состояний в выходной байтовый вектор перед кодированием base-64. Я нашел псевдокод Кекчака неинтуитивным; Псевдокод NIST под конструкцией губки был понятнее.
В ржавчине
/** * From https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.202.pdf * # 4 and Algorithm 8, steps 7 to 10 * * This function extracts bytes from the state array and returns * a vector capturing the number of digest bytes corresponding to * the request digest output size. */ fn squeeze (&mut self, rate: usize, md_size: usize) -> Vec<u8> { let num_words = rate / 64; let len_bytes = md_size / 8; let mut bytes = Vec::with_capacity(len_bytes * 2); // Iterates until sufficient bytes have been read to fill the // final message digest byte vector while bytes.len() < len_bytes { // Read the first 'num_words` integers from the state array for i in 0..num_words { bytes.extend_from_slice(&self.state[i / 5][i % 5].to_le_bytes()); } if bytes.len() < len_bytes { // If we have more bytes to read, permute // the state for the next round of reading self.permute(); } } // Return the first len_bytes of the result, consistent // with requested message digest size return bytes[0..len_bytes].to_vec(); }
Ранее я описал общие различия между этим подходом и более старыми методами хэш-функции для извлечения значений дайджеста из хеш-состояния. Выходные байты извлекаются партиями с начала массива состояний (как в state[0][0]
). По достижении определенной длины остальная часть состояния отбрасывается, и весь массив проходит еще один раунд из 24 перестановок.
Последний шаг — кодирование байтов в базе 64.
~/code/sha-3 ~>> ./target/release/sha3 --algo 224 --string rust c44aa66c6a741d515297a91554d6a669323288ba1d42a7402938e5df ~/code/sha-3 ~>> ./target/release/sha3 --algo 256 --string rust dcd84e60f950eebbeaf1db530fd4ed1ccf61aab94b558653072a205fb0fedee7 ~/code/sha-3 ~>> ./target/release/sha3 --algo 384 --string rust e7e13b16b131f1a8bc3ef00601beb13031d82de118bf5204757d3f5d8fa3c69431d0944ed9ee8cfd479acb0b16a93941 ~/code/sha-3 ~>> ./target/release/sha3 --algo 512 --string rust 31805399bdec85ad94582da495b92cc3054835ac97d475ff1946ba85b2d9bd02f6ac2d8ccc6b72622de5c63a5eee28c1323bfec26fc56116193ae988c59b3bc8 ~/code/sha-3 ~>> time ./target/release/sha3 --test [OK] SHA3-224 ("") [OK] SHA3-224 ("abcde") [OK] SHA3-224 ("6acfaab70afd8439cea3616b41088bd81c939b272548f6409cf30e57") [OK] SHA3-256 ("") [OK] SHA3-256 ("abcde") [OK] SHA3-256 ("d716ec61e18904a8f58679b71cb065d4d5db72e0e0c3f155a4feff7add0e58eb") [OK] SHA3-384 ("") [OK] SHA3-384 ("abcde") [OK] SHA3-384 ("348494236b82edda7602c78ba67fc3838e427c63c23e2c9d9aa5ea6354218a3c2ca564679acabf3ac6bf5378047691c4") [OK] SHA3-512 ("") [OK] SHA3-512 ("abcde") [OK] SHA3-512 ("1d7c3aa6ee17da5f4aeb78be968aa38476dbee54842e1ae2856f4c9a5cd04d45dc75c2902182b07c130ed582d476995b502b8777ccf69f60574471600386639b") All 12 tests completed successfully! real 0m0.007s user 0m0.002s sys 0m0.003s
Для читателей, которые ищут простое пошаговое руководство или удобочитаемую реализацию SHA-3, надеюсь, вы нашли это полезным.
Ржавчина
Что касается самого Rust, вот несколько мыслей:
- Как человек, привыкший к C и C++, я обнаружил, что борьба с типами Rust и системами безопасности разочаровывает — поначалу
- После того, как программы скомпилировались, было приятно запускать их без необходимости отладки ошибок сегментации.
- Я думал, что многие из встроенных методов коллекций и примитивных типов удобны, интуитивно понятны и очень хорошо документированы.
- Производительность этих программ кажется сравнимой со встроенными собственными хеш-утилитами MacOS.
Cargo
отлично подходит для управления пакетами и зависимостями.