Всем привет. Прошло уже больше 4-х месяцев с того момента, как я начал реализацию гибридного протокола шифрования на базе TypeScript. Сегодня я наконец‑то дошел до публикации новой версии, учтя все прошлые проблемы и недочеты протокола и хотел бы разобрать, как сделать постквант удобным, не сломав криптографию.

В этой статье разберем зачем же нужен ленивый keystream, ротация ключей в один токен и зачем я полез в защиту NTT. Этот рассказ затронет каждое решение, отвечая на вопросы от «почему именно так» до «что было лучше отбросить».


0. Точка старта или каким был QuarkDash до этого

Изначально, QuarkDash был честным гибридом Ring‑LWE (с параметрами N=256, Q=7681) и KDF на базе SHAKE256, а шифрование проводилось с выбором ChaCha / Gimli + MAC на SHAKE256. Всё было написано на чистом TypeScript, без использования сторонних зависимостей. Единственное что я написал на WASM (чистом C) — это обработку SHAKE256 для ускорения вычисления.

Однако, после нескольких месяцев использования в проде, я заметил для себя несколько вещей:

  • Keystream был реализован слишком жадно. 32 блока (2 КБ) сразу, даже если нужно 100 байт с середины 100-МБ файла. Памяти было не сладко, GC тоже не радовался. Для стримов это было проблемой.

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

  • Пароль!= ключ. Не было способа «дай фразу и получи ключ в 32B». Приходилось выносить PBKDF2 наружу.

  • NTT был наивным. Не было никакого блайндинга, практически не было проверок, с Math.random в прошлом и другими проблемами. Работало, но по факту слабо защищала от тайминга и глюков.

  • Nonce был статическим 12×0, что приводило к проблемам переиспользования keystream.

  • И ряд других мелких проблем, которые накладывались и выдавали не самую оптимальную картину для алгоритма, который используется каждый день.

Целью обновлений стало закрыть все пробелы, при этом минимально касаясь изменений в API, чтобы просто и без боли внедрить новую версию в текущие процессы.


1. Ленивый Keystream — почему не еще один буфер?

Примерно так в моем понимании теперь выглядит Keystream
Примерно так в моем понимании теперь выглядит Keystream

Одна из ключевых задач переработки — оптимальная работа с памятью, что было особенно критично для работы с большими данными (аудио / видео / документы / историей сообщений). Так же нужно было сохранить максимальную нативность.

Было много идей, которые были так или иначе отброшены в процессе работы:

  • Оставить батч 32 блока. Просто, но на стримах 2 ГБ ты или держишь весь поток в RAM, или режешь вручную. Посмотреть в определенный блок памяти просто невозможен без генерации 1 МБ мусора.

  • Node Stream / Web Streams. Тяжело, тянет полифилы, не работает нормально в воркерах.

  • Сделать стейтфул keystream (хранить счетчик внутри шифра). Ломает дешифровку, поскольку пир должен знать точный offset.

Что в итоге сделал:

Для начала сделал общий интерфейс и абстрактный класс для нашего ленивого keystream:

getBytes(offset, length)         // для получения хвоста из любых блоков
xor(data, offset)                // XOR без аллокации лишнего мусора
xorInto(input, output, offset)
blocks(start)                    // бесконечный генератор
seek/tell/rewind/read            // Утилитарные методы просмотра блоков

Почему именно так:

  • Один метод для наследника: generateBlock(i). Для ChaCha это 20 раундов, для Gimli 24. Всё остальное это утилиты для нарезки, кэша, просмотра данных уже в базовой реализации класса.

  • Кэш 64 блока (LRU). 64×64B = 4 КБ для ChaCha, 64×48B = 3 КБ для Gimli. Хватает, чтобы XOR на 64KB не пересчитывал одно и то же, но и не раздувало память. В отдельных случаях добавил метод setCacheLimit() если нужно.

  • Почему 64, а не 128? Бенчмарк показал, что больше 64 почти не даёт выигрыша, а меньше 32 начинает бить по результату получения буффера с рандомным офсетом. Поэтому, 64 — это золотая середина.

  • Почему не SharedArrayBuffer? Не везде доступен, а выигрыш на 64B блоках мизерный.

По итогу в QuarkDash теперь внедрен nonce на каждое сообщение по‑умолчанию, где метаданные это 8 байт на временную метку и 4 байта на sequence. Раньше был один keystream на все сообщения, а теперь свой на каждое сообщение без дополнительного поля в пакете.

// Пример создания keystream
const ks = chacha.createKeystream();
ks.getBytes(1_000_000, 64*1024) // теперь 1.9ms вместо 47ms на генерацию 2MB

2. Ещё больше безопасности с использованием ротации ключей

Зачем вообще нужна ротация ключей?

Forward secrecy у QuarkDash и так есть на уровне сессии, но внутри сессии ключ жил вечно. Это значит, что утечка через месяц = расшифровка всего трафика. TLS ротирует каждые 64 МБ / 10к сообщений: я сделал так же, но проще в использовании.

Давайте для начала разберемся, какие вообще есть еще альтернативы:

  • Полностью заново пройти хендшейк (новый Ring‑LWE). Безопасно, но 2–3ms и 1 КБ трафика. Для IoT больно.

  • KDF‑цепочка, где новый ключ будет старым, но снова пройденным через SHAKE без соли. Детерминированно, но если злоумышленник угадал один ключ, то угадает все дальше.

  • Авто‑ротация по таймеру внутри самого encrypt. Удобно, но неявно, пир может не успеть, получим рассинхрон.

Что выбрал я:

  • Явный токен. Первый пир получает новый ключ (зашифрованный 0×51 | счетчик | соль), второй пир применяет токен (ту же соль).

  • Для этого нужен всего один вызов с каждой стороны. Есть вспомогательные методы для ручного вызова ротации ключей.

  • KDF: обновляем KDF с использованием старого ключа и MAC, соли и счетчика — это 64B. Первые 32B — ключ сессии, вторые — macKey. Старые ключи затираем из памяти.

  • Добавляем политики, а не магию: возможность автоматической ротации по количеству байт / сообщений или через интервалы.

// В итоге, простая ротация ключа
if (peer.needsRekey()) await peer.rekey().then(t => peer2.applyRekey(t))

По дефолту стоят значения на ротацию каждые 64МБ или 10К сообщений. Почему вообще так? Баланс: если делать чаще, получаем больше оверхеда (0.08ms на токен), если реже, то больше данных под одним ключом. Можно менять на лету.


3. Введение Passphrase через PBKDF2 + Argon2id‑lite

Зачем вообще нужен Passphrase (он же пароль)? Иногда, у нас нет как такового хэндшейка между пирами (работа в CLI, локальные файлы, либо не поддерживается хэндшейк по алгоритму соединений).

Для гибкости использовано два алгоритма работы — классический PBKDF2 и более интересный Argon2id в облегченной версии.

Почему PBKDF2-HMAC‑SHA256?

  • Стандарт, есть в Node (crypto.pbkdf2 в 2–3 раза быстрее), проверен RFC 6070. Я использую SHA256 (не SHA1).

  • Дополнительная реализация без зависимостей: HMAC‑SHA256 вручную (oPad/iPad). Если Node недоступен то есть fallback в чистый TypeScript. Пароль из строки это перевод текста в буфер для ключа и затирание памяти после.

Почему Argon2id‑lite, а не bcrypt/scrypt?

  • bcrypt с лимитами в 72B, не усложняет взлом через память.

  • scrypt хорош, но требует много зависимостей и нативного модуля.

  • Настоящий argon2 нативный node‑argon2, тянет node‑gyp и с браузером уже будет проблема.

Нам нужна лёгкая, но защищенная по памяти версия без натива, чтоб работало и в браузере, и на твоей лампочке в спальне. Сделал argon2id‑lite на SHAKE256:

  1. Первый этап — обработка пароля + соли и параметров через SHAKE256.

  2. Второй этап — растягивание ключей по памяти на запрошенное количество KB.

  3. Третий этап — мешаем псевдо‑рандомные блоки в памяти через SHAKE256 с временной сложностью.

  4. Финально — берем первые 8 блоков и прогоняем через SHAKE256, остальное в памяти затираем.

Таким образом, это не полный Argon2, но даёт главное: заставить брутфорс держать гигабайты. Параметры по умолчанию 32MB с временной сложностью 3, но можно для тестов или менее критичных данных взять параметры в 8MB и одинарной временной сложностью (2.5ms vs 36ms).

Пример использования:

QuarkDashPassphrase.pbkdf2Sync("pwd", salt, 100_000, 32)
QuarkDashPassphrase.argon2idSync("pwd", salt, 32, 3, 32)
await QuarkDashPassphrase.derive("secret", {algorithm:"argon2id"})
const {sessionKey, macKey} = await QuarkDashPassphrase.deriveKeyForQuarkDash("pwd", salt)

4. Как защитить математику, не угробив скорость. Разбираем Hardened NTT

Одно из узких мест в прошлой версии протокола было слабо защищенное NTT. В этой версии я решил закрыть все слабые места, при этом сохранив баланс по производительности.

Что было не так в прошлой версии:

  • Сериализация, к примеру -1 (0xFFFF), а де‑сериализатор ждал <Q. Валидация отсутствовала, что приводило к проблеме.

  • b = (as+e) % Q в реализации JS давал отрицательный остаток e<0.

  • У NTT вообще не было блайндинга, дополнительных проверок, а wlen считался заново на каждом уровне. Это и минус по защите, и минус по производительности.

Как я улучшил реализацию NTT, сделав его более стойким и быстрым:

  • Добавил нормализацию везде, где она должна быть, через (v%Q)+Q)%Q.

  • Добавил блайндинг: a·r, b·r⁻¹, где r берется из 2B рандома, а r⁻¹, считается через modInverse. Таким образом, произведение a·b не меняется, а след в кэше/времени размывается.

  • Двойные проверки: прогоняем NTT второй раз и сравниваем их, отлавливая ошибки.

  • wlen теперь кешируется, через powMod.

  • Ввел циклы фиксированной длины, а также валидацию полиномов, где v ∈ [-Q, Q)

Ну и включается защита элементарно:

lwe.setNTTProtection({blinding:true, doubleCheck:true})

Цена всего этого: Генерация ключа 0.58ms → 0.73ms, а хэндшейк 2.2ms →2.4ms, что почти бесплатно.


5. В качестве бонуса, добавил обертки для транспорта

Для того, чтобы вы могли прозрачно внедрить QuarkDash поверх популярного транспорта, сделаны простейшие обертки (конечно, в реальности они могут быть сложнее, но в качестве примера использования или для тестов — вполне хватает).

Из обреток добавил:

  • WebSocket: подходит и браузерный WebSocket, и ws из Node. Обертка подписывается на message, расшифровывает и отдаёт в onDecrypted. На каждое подключение свой ключ.

  • HTTP: использует заголовок x-qd-encrypted, а тело octet-stream, факт шифрования виден. Так же есть простой middleware для фреймворка express и fetchWrapper для запросов из браузера.

  • gRPC: здесь я сделал два пути: интерцепторы клиента и сервера для нативного API и прокси обертка, как самый простой способ, без правки.proto. Шифровать можно буфер, строки или объекты (но объект будет прогнан через JSON.stringify).

Все обертки тонкие, без зависимостей, падают тихо (try/catch внутри), не ломают сокеты.


6. Итоги. Зачем это вообще нужно

Сам протокол нужен для максимально удобного, пост‑квантового шифрования, защищенной там, где это действительно нужно, при этом без необходимости дописывать отдельные компоненты. Постоянная работа и тестирование, показывает, что всегда есть, где что‑то можно улучшить, однако нужно не забывать, что при улучшениях, стоит учесть, чтобы не сломалось ничего старого.

Какие результаты были достигнуты?

  • Оптимизация памяти при шифровании больших данных (экономия в 3 раза без перегрузки сборщика мусора).

  • Защита математических функций, ценой всего в пару десятков миллисекунд.

  • Защита от атак в будущем через ротацию ключей и улучшенные механизмы протокола.

  • Плавный переход с минимумом изменений в API.

Где вообще нужен такой протокол?

  • Там, где нужна пост‑квантовая устойчивость, а также при работе с шифрованием больших файлов или данных.

  • Где вам важно, чтобы цепочка шифрования была полноценной, а не просто «прогнал через AES», с учетом различных видов атак.

  • На realtime‑обмене сообщениями, где критичен обмен ключами и безопасность соединения, в балансе с оптимизацией и скоростью.

  • Если хотите разобраться, как работать с защищенными протоколами или использовать, как готовую альтернативу протоколам, вроде MTProto.

Буду рад вашим мыслям по улучшению и доработке протокола:

https://github.com/DevsDaddy/quarkdash

Спасибо за прочтение.