Слогер Создать блог
Разработка

Когда гнуть, а когда ломать: миграция на постквантовую криптографию в Rust

В проекте Anyhide применили две противоположные стратегии миграции — гибкую обратную совместимость для долгоживущих данных и жёсткий разрыв для временных. Разбираемся, почему так и как это реализовано.

Большинство проектов, работающих с криптографией, откладывают миграцию на постквантовые алгоритмы. Стандарт ML-KEM-768 (FIPS 203) появился в 2024 году, но чистые Rust-библиотеки ещё молоды, аудиты не завершены, а рост размера передаваемых данных настораживает. Так что почти все ждут.

Я решил не ждать по одной конкретной причине: коды Anyhide постоянны. Они попадают в чаты, QR-коды на бумаге, публичные места. Атакующий, записывающий трафик сегодня, может сохранить его на десять лет и расшифровать, когда появятся квантовые компьютеры. Это угроза «собери сейчас — расшифруй потом», и именно её решает постквантовая криптография.

Чего я не ожидал — и о чём эта статья — так это того, что миграция в итоге использовала две противоположные стратегии в одном проекте, в зависимости от того, какие данные производит каждая подсистема.

Две подсистемы

В Anyhide есть два уровня, работающих с асимметричной криптографией:

  • Коды Anyhide. Стеганографические коды, которые сохраняются на диск, встраиваются в носители, передаются через QR. Долгоживущие. Важна обратная совместимость. Уже существуют коды, созданные пользователями и лежащие в бэкапах, которые должны продолжать декодироваться после миграции.
  • Чат-протокол. P2P-чат через Tor с Double Ratchet. Всё в RAM — сессии живут и умирают вместе с соединением, ничего не сохраняется, никаких исторических данных после завершения чата нет.

Одни и те же криптооперации, но совершенно разные жизненные циклы данных. Поэтому стратегии миграции разошлись: для кодов нужно было гнуться, для чата — ломать.

Стратегия 1: гнуться (коды Anyhide)

Для кодов ограничение жёсткое: каждый существующий код v6 должен продолжать декодироваться побайтово идентично с новой сборкой. Пользователи не будут перекодировать свои бэкапы. Их классические PEM-ключи всё ещё должны работать. Миграция должна быть аддитивной.

Механизм — магический префикс в формате данных:

// src/crypto/mod.rs pub const HYBRID_WIRE_MAGIC: [u8; 4] = *b"AHV7"; pub const HYBRID_WIRE_VERSION: u8 = 1; pub const HYBRID_WIRE_PREFIX_LEN: usize = HYBRID_WIRE_MAGIC.len() + 1; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WireFormat { ClassicalV6, HybridV7, } pub fn detect_wire_format(ciphertext: &[u8]) -> WireFormat { if ciphertext.len() >= HYBRID_WIRE_PREFIX_LEN && ciphertext[..HYBRID_WIRE_MAGIC.len()] == HYBRID_WIRE_MAGIC { WireFormat::HybridV7 } else { WireFormat::ClassicalV6 } }

Гибридный кодировщик всегда добавляет этот 5-байтовый префикс в начало зашифрованных данных. Классический — нет. Декодер смотрит на первые байты (после декодирования base64) и направляет в нужный слой расшифровки — без предварительного дешифрования.

Неочевидное свойство здесь — безопасность от коллизий. Коды v6 начинаются с 32-байтового эфемерного открытого ключа X25519, который равномерно случаен. Вероятность того, что легитимный код v6 случайно начнётся с байтов AHV7, равна ровно 1 / 2^32, или примерно один на четыре миллиарда.

Даже при такой коллизии ничего катастрофического не произойдёт. Декодер Anyhide по задумке никогда не выдаёт ошибку — он возвращает только детерминированный мусор на плохих входных данных. Так что неправильная маршрутизация даёт бессмыслицу, пользователь видит мусор, и у атакующего нет сигнала ошибки, который можно было бы прощупать.

Это возможно только потому, что Anyhide изначально имел свойство «никогда не выдавать ошибку» для правдоподобного отрицания. Если бы декодер выдавал ошибки на плохих данных, диспетчеру пришлось бы быть более защищённым, а API разрослось бы.

Стратегия 2: ломать (чат-протокол)

Для чата расчёт другой. Чат живёт только в RAM:

  • Нет сохранённых шифротекстов. Как только сессия закрывается, сообщения исчезают.
  • Нет долгосрочных ограничений на совместимость формата. На диске ничего не нужно сохранять.
  • Рукопожатие выполняется при каждом новом соединении.

Так что чистый разрыв дешевле, чем обратная совместимость. Чат-протокол перешёл с v1 на v2 с жёстким отклонением:

// src/chat/config.rs pub const CHAT_PROTOCOL_VERSION: u8 = 2; // src/chat/session.rs pub fn receive_message(&mut self, wire: WireMessage) -> Result<...> { if wire.version != CHAT_PROTOCOL_VERSION { return Err(ChatError::VersionMismatch { expected: CHAT_PROTOCOL_VERSION, got: wire.version, }); } // ... }

Пиры v2 отклоняют соединения v1, и наоборот. Миграция UX: перегенерируйте свою чат-идентичность с anyhide keygen --hybrid, заново поделитесь QR с собеседниками, готово. Никакие данные не теряются, потому что не было сохранённых данных.

Урок, к которому я постоянно возвращаюсь: правильная стратегия миграции определяется жизненным циклом данных, а не эстетикой. Две части одного проекта могут легитимно использовать противоположные стратегии, если они хранят данные на разных временных шкалах.

Гибридный KEM

Обе стратегии используют один и тот же базовый примитив: гибридный KEM, объединяющий X25519 с ML-KEM-768. Комбинатор прост:

// src/crypto/hybrid_kem.rs fn combine_secrets(classical_ss: &[u8; 32], pq_ss: &[u8; 32]) -> SharedKey { let mut ikm = [0u8; 64]; ikm[..32].copy_from_slice(classical_ss); ikm[32..].copy_from_slice(pq_ss); let hk = Hkdf::<Sha256>::new(None, &ikm); let mut out = [0u8; SHARED_KEY_SIZE]; hk.expand(b"ANYHIDE-HYBRID-KEM-V1", &mut out) .expect("32 bytes is valid output length"); ikm.zeroize(); SharedKey::new(out) }

Общий секрет — HKDF-SHA256(classical_ss || pq_ss) с информационной строкой для разделения доменов. Буфер IKM обнуляется после вызова HKDF, чтобы избежать утечки ключевого материала из памяти.

Аргумент безопасности: комбинированный ключ как минимум так же силён, как сильнейший из двух компонентов KEM. Если ML-KEM-768 будет взломан в будущем непредвиденным криптоанализом, X25519 всё ещё защищает сообщение. Если квантовый компьютер взломает X25519, ML-KEM-768 всё ещё защищает его. Конфиденциальность теряется только если оба взломаны одновременно — гораздо более высокий порог, чем ставка на один из них.

Это важно, потому что библиотека ml-kem из RustCrypto, от которой зависит Anyhide, находится на версии 0.3 и не прошла аудит. Чистый ML-KEM в 2026 году — это система, основанная на вере. Гибридный режим — это страховка, которая делает его развёртывание приемлемым для инструмента, работающего с реальными проблемами приватности.

Рукопожатие в форме PQXDH

Внутри чат-протокола рукопожатие представляет собой двунаправленный обмен KEM — постквантовый аналог взаимного ECDH. Одного направления недостаточно: если бы только отвечающий выполнял инкапсуляцию, он бы единолично контролировал всю энтропию сессионного секрета. Классический ECDH симметричен по своим входным данным по построению; для KEM нужно инкапсулировать в обоих направлениях, чтобы восстановить это свойство.

// Initiator -> Responder: HandshakeInit { eph_pubkey_hybrid } // Responder -> Initiator: HandshakeResponse { eph_pubkey_hybrid, kem_ct_to_init } // Initiator -> Responder: HandshakeComplete { kem_ct_to_resp } // Both sides derive: let master = derive_master_secret(&ss_resp_to_init, &ss_init_to_resp); // ^ HKDF info ANYHIDE-CHAT-V2-MASTER

Это та же форма, что и в предложении PQXDH от Signal: обе стороны инкапсулируют относительно статического (или в данном случае эфемерного) гибридного открытого ключа другой стороны, и мастер-секрет сессии смешивает два общих секрета. Симметричный вклад энтропии, взаимная привязка к транскрипту.

Рэтчет KEM (ротация ключей на каждое сообщение, как в классическом Double Ratchet, но с шагами kem_ratchet_send / kem_ratchet_receive вместо шагов DH) строится поверх этого мастер-секрета.

Проблема мнемоники на 96 байт

Один угол миграции пришлось разрабатывать с нуля: резервное копирование BIP39. Стандартный BIP39 ограничен 24 словами = 256 бит = 32 байта энтропии + 8-битная контрольная сумма SHA-256. Классические ключи шифрования занимают 32 байта, поэтому они помещаются.

Гибридные секреты не помещаются. Они занимают 96 байт:

  • 32 байта секрета X25519
  • 32 байта сида ML-KEM d
  • 32 байта сида ML-KEM z

(Ключ ML-KEM хранится как сид FIPS 203 d || z, а не в расширенной форме на 2400 байт, которая устарела и вызывает панику в некоторых библиотеках при сериализации. Хранение сида и восстановление ключа декансуляции всегда корректно по FIPS.)

Я рассмотрел три варианта для формата резервной копии:

  • Фраза из 27 слов по кастомному списку. Кодирует 297 бит, достаточно для 96 байт плюс контрольная сумма. Отклонено, потому что ни одна библиотека BIP39 не поддерживает нестандартное количество слов, а использование кастомного кодировщика означает, что для восстановления моей резервной копии нужен мой точный декодер.
  • Более крупный кастомный словарь. Мог бы закодировать 96 байт меньшим количеством слов. Отклонено по той же причине — совместимость умирает, если словарь не стандартен.
  • Три независимые 24-словные фразы BIP39. Каждая...

В итоге был выбран третий вариант: три независимые 24-словные фразы BIP39, каждая кодирует 32 байта одного из компонентов. Это неэлегантно, но полностью совместимо с любым BIP39-кошельком. Пользователь получает три списка слов, которые нужно сохранить в правильном порядке. Неудобно, но надёжно.

Выводы

Миграция на постквантовую криптографию — это не только техническая задача, но и вопрос проектирования. В Anyhide мы применили обе стратегии: гибкую для долгоживущих кодов и жёсткую для эфемерного чата. Главный урок: не существует единого правильного подхода. Всё зависит от того, как долго живут ваши данные и насколько критична обратная совместимость.

По материалам: dev.to. Текст переработан редакцией Слогера.

← На главную

Рекламное место — Конец поста
Реклама · Слогер

Комментарии (0)

Войдите, чтобы комментировать.

Пока нет комментариев. Будьте первым.