Ориентация главы
Главы 74–76 дали вам все алгоритмические примитивы — но в реальном мире взломанные системы в большинстве своём пали не от взлома алгоритмов, а от ошибок системного слоя: ключ попал в журнал, функция сравнения утекает таймингом, неверный источник случайности, самодельная комбинация протокола. Эта глава — «инструкция по применению» трёх предыдущих и сводка дисциплины тома 8 перед переходом от примитивов к сертификатам и TLS. Четыре темы: дисциплина постоянного времени (почему сравнение MAC обязано не выходить досрочно), жизненный цикл ключей (генерация — использование — хранение — уничтожение, свои правила и API на каждом отрезке), дисциплина источников случайности (граница между безопасственной и обычной случайностью), дисциплина комбинирования (не изобретать протоколы, единственность отказа, привязка контекста). Образец-финал crypto/session собирает примитивы трёх глав в одну полную временную сессию — это скелетная миниатюра TLS 1.3; рукопожатие главы 82 стоит на ней, лишь добавляя сертификаты и машину состояний.
77.1引入
Взгляните на абстракции трёх реальных аварий. Первая: сервис проверяет сеансовые токены через memcmp — атакующий меряет время отклика и по байтам подбирает законный токен: первый байт верен — отклик медленнее на десятки наносекунд; миллион запросов полностью «ощупывает» токен. Вторая: разработчик очищает ключ memset-ом перед освобождением памяти — компилятор решает, что эту память «больше не читают», и удаляет обнуление целиком; ключ открытым текстом остаётся в куче, и последующее выделение его считывает. Третья: два модуля порознь генерируют «случайные» challenge-значения предсказуемым обычным источником — атакующий предсказывает вывод, и аутентификация существует только на бумаге.
Общее у трёх аварий: алгоритмы все верны, вызовы все неверны. Безопасность криптографических API существует только в контексте «правильного употребления» — три неприметные функции xrtConstTimeEqual, xrtSecureZero и xrtSecureRandom и есть выходы, приготовленные XRT для этих трёх классов аварий. Глава разъясняет их вместе с большей «картиной комбинирования».
77.2概念
Трёхслойная модель провала: на каком слое происходит ошибочное употребление
Представьте систему безопасности тремя слоями; атакующий всегда выбирает мягчайший:
Практический самотест: написав строку криптокода, спросите «какой слой эта строка ломает». memcmp над шифртекстом ломает системный слой; «проверка не прошла, но продолжаем обработку» ломает слой протокола; выбор MD5 ломает слой алгоритмов. Из трёх слоёв алгоритмический ошибается менее всего (достаточно верного API), поэтому внимание инженера распределяется наоборот — больше всего на системный, затем протокольный, меньше всего на алгоритмический. Список ловушек главы целиком родом из системного слоя.
Дисциплина постоянного времени: сравнение — тоже поверхность атаки
memcmp(Left, Right, N) выходит досрочно на первом различающемся байте — эта «быстрота» и есть уязвимость: время сравнения пропорционально длине общего префикса, и атакующий дистанционным хронометрированием восстанавливает секрет побайтно (токен, MAC, ключ). xrtConstTimeEqual(Left, Right, N) накапливает различие поразрядно и выносит вердикт в конце — время исполнения зависит только от N. Две точные границы: она не скрывает само N (длина публична); она и не полная защита от боковых каналов (энергопотребление и кеш — другие измерения) — но на самой реалистической поверхности «дистанционного тайминга» она необходима. Объекты применения: сравнение MAC/тегов, токенов, общих секретов, отпечатков шифртекста — всякое сравнение, где одна сторона — секрет. Для данных, публичных с обеих сторон (контрольные суммы файлов, ключи кеша), хватает memcmp: постоянное время не бесплатно по производительности.
Четыре отрезка жизненного цикла ключа
- Генерация: симметричные ключи и приватные — только из
xrtSecureRandom(следующий раздел) или деривации KDF; фиксированные ключи тестовых векторов обязаны быть помечены «только для воспроизведения», как в образцах. - Использование: ключ живёт только в стековом буфере или специализированной структуре состояния (расписание ключа
xaesgcm, развёрнутое состояниеxed25519key); не копируется в другие буферы — каждая лишняя копия расширяет поверхность уничтожения. - Хранение: ключ никогда не входит в журналы (Logger главы 38 — не исключение), в сообщения об ошибках, в персистентные структуры файла подкачки (это за пределами возможностей
xrtSecureZero— контракт прямо говорит: он покрывает только данный вызовом интервал; регистровые копии компилятора, crash-дампы и подкачка ОС требуют системных мер: mlock, отключение дампов, шифрование диска). - Уничтожение:
xrtSecureZeroобнуляет volatile-записями; обычныйmemsetкомпилятор может удалить как мёртвое хранилище (вторая авария из начала); объекты с состоянием — специализированнымиClear/KeyClear(они знают все чувствительные углы структуры). Чистить и успешный путь, и путь отказа — cleanup-сегмент образцов главы и есть шаблон.
Дисциплина источников случайности: две случайности не смешиваются
XRT делит случайность на два мира: обычная (глава 11, последовательность, посеянная xrtFastRandSeed, — быстрая, сеемая, воспроизводимая) для тестовых данных, распределения нагрузки, тасования; безопасственная (xrtSecureRandom) для всех ключей, токенов, солей, nonce — платформенный CSPRNG (Windows CNG, Linux getrandom), несеемый, непредсказуемый. Пробный камень границы: повлечёт ли предсказание этого значения последствия для безопасности? Да — безопасственная случайность, пусть она и на два порядка медленнее. Обратное правило не менее важно: безопасственная случайность не для воспроизводимых сцен (тестовые векторы, данные базисов) — не ради производительности, а потому что детерминированный вывод попросту не её обещание. Криптомодуль зависит от источника через независимую возможность RANDOM_SECURE (таблица обрезки главы 74) — файловые и сетевые модули, переиспользующие базовую случайность, не утаскивают за собой криптоалгоритмы: эта граница позволяет «серьёзность источника случайности» и «лёгкость модулей» сосуществовать.
Дисциплина комбинирования: не изобретайте протоколы
С примитивами в руках главный соблазн — «слепить свой криптопротокол»; четыре замены и одно железное правило:
- Сперва ищите готовый протокол: транспортная безопасность — прямо TLS главы 82; токены — HMAC или подпись; шифрование файлов — одиночная запись AEAD. Каждое решение самодельного протокола (порядок полей, машина состояний рукопожатия, семантика отказа) заставляет заново наступать на грабли, по которым прошло всё индустрию.
- Отказ обязан быть единственным: проверка не прошла — это отказ; без «деградировать и продолжить», без «предупредить, но пропустить». Атаки на даунгрейд (заставить стороны откатиться к слабым параметрам) эксплуатируют именно путь «после отказа продолжаем».
- Привязка контекста: деривация ключей связывает назначение через
info(глава 74), шифрование записей связывает номер и версию через AAD (глава 75), дайджест рукопожатия связывает все его сообщения (глава 82) — каждый слой вписывает в область аутентификации «кто я и который по счёту», и атакующий не может переставить законный шифртекст в другую позицию для повтора. - Временные ключи прежде: раз можно сеансовый временный — не пользуйтесь долгосрочным; радиус утечки решает масштаб аварии. Прямая секретность TLS 1.3 — предельная форма этой дисциплины: на каждое соединение новая пара x25519, долгосрочный приватный ключ только подписывает рукопожатие и в шифровании не участвует.
77.3示例
Первая полная программа: вход в два железных правила
Программа ниже из examples/crypto/core/main.c — метаданные дайджеста, сравнение постоянного времени и безопасное обнуление одним махом:
#include <stdio.h> #include <xrt.h> */ /* 展示摘要元数据、常量时间比较和敏感数据清理。 */ int main(void) { uint8 Left[XRT_SHA256_SIZE] = { 1u, 2u, 3u }; uint8 Right[XRT_SHA256_SIZE] = { 1u, 2u, 3u }; bool bEqual; /* 算法元数据不要求把对应摘要实现编入程序。 */ if ( xrtCryptoHashSize(XCRYPTO_HASH_SHA256) != sizeof(Left) ) { return 1; } /* 密钥、标签和摘要等固定长度数据应使用常量时间比较。 */ bEqual = xrtConstTimeEqual(Left, Right, sizeof(Left)); xrtSecureZero(Left, sizeof(Left)); xrtSecureZero(Right, sizeof(Right)); printf("equal: %s\n", bEqual ? "yes" : "no"); return bEqual ? 0 : 1; }
Что только что произошло. (1) xrtCryptoHashSize — чистые метаданные: запросить длину SHA-256 не значит втянуть реализацию SHA-256; вычисление ёмкости расцеплено с конфигурацией компиляции. (2) Два буфера дайджеста сравниваются xrtConstTimeEqual — здесь демонстрация «содержимое совпадает», в продакшене это «моё вычисленное MAC против полученного»: даже если подделка атакующего отличается от истины только последним байтом, время сравнения совершенно одинаково — тайминг-атаке не за что зацепиться. (3) Сразу после сравнения оба буфера проходят xrtSecureZero — они изображают ключевой материал; volatile-запись гарантирует, что это обнуление не исчезнет. Вся программа меньше 20 строк — но это три самые частые функции тома.
Вторая полная программа: одна полная временная сессия (скелетная миниатюра TLS 1.3)
Вторая программа из examples/crypto/session/main.c сцепляет примитивы глав 74/75/76 в настоящую протокольную форму:
#include <stdio.h> #include <string.h> #include <xrt.h> */ /* 组合 X25519、HKDF-SHA256 和 ChaCha20-Poly1305 建立一次临时会话。 */ int main(void) { static const char Message[] = "authenticated session message"; static const char Salt[] = "xrt-session-v1"; static const char Aad[] = "record:0"; uint8 AlicePrivate[XRT_X25519_PRIVATE_SIZE]; uint8 AlicePublic[XRT_X25519_PUBLIC_SIZE]; uint8 BobPrivate[XRT_X25519_PRIVATE_SIZE]; uint8 BobPublic[XRT_X25519_PUBLIC_SIZE]; uint8 AliceShared[XRT_X25519_SHARED_SIZE]; uint8 BobShared[XRT_X25519_SHARED_SIZE]; uint8 Transcript[XRT_X25519_PUBLIC_SIZE * 2u]; uint8 SessionKey[XRT_CHACHA20_POLY1305_KEY_SIZE]; uint8 Nonce[XRT_CHACHA20_POLY1305_NONCE_SIZE] = { 0 }; uint8 Buffer[96]; size_t iMessageSize = sizeof(Message) - 1u; size_t iSealedSize = iMessageSize + XRT_CHACHA20_POLY1305_OVERHEAD; bool bValid = false; /* 双方共享秘密必须相同,且对端公钥应进入派生上下文。 */ if ( !xrtX25519KeyPair(AlicePrivate, AlicePublic) || !xrtX25519KeyPair(BobPrivate, BobPublic) || !xrtX25519Shared(AlicePrivate, BobPublic, AliceShared) || !xrtX25519Shared(BobPrivate, AlicePublic, BobShared) || !xrtConstTimeEqual( AliceShared, BobShared, sizeof(AliceShared) ) ) { goto cleanup; } memcpy(Transcript, AlicePublic, sizeof(AlicePublic)); memcpy( Transcript + sizeof(AlicePublic), BobPublic, sizeof(BobPublic) ); /* 派生后的会话密钥才进入 AEAD,原始共享秘密不直接使用。 */ if ( !xrtHkdfSha256( Salt, sizeof(Salt) - 1u, AliceShared, sizeof(AliceShared), Transcript, sizeof(Transcript), SessionKey, sizeof(SessionKey) ) ) { goto cleanup; } /* 新临时密钥下的首条记录使用序号零,后续记录必须递增 nonce。 */ memcpy(Buffer, Message, iMessageSize); if ( !xrtChaCha20Poly1305Seal( SessionKey, Nonce, Aad, sizeof(Aad) - 1u, Buffer, iMessageSize, Buffer, sizeof(Buffer) ) || !xrtChaCha20Poly1305Open( SessionKey, Nonce, Aad, sizeof(Aad) - 1u, Buffer, iSealedSize, Buffer, sizeof(Buffer) ) ) { goto cleanup; } bValid = memcmp(Buffer, Message, iMessageSize) == 0; cleanup: xrtSecureZero(AlicePrivate, sizeof(AlicePrivate)); xrtSecureZero(BobPrivate, sizeof(BobPrivate)); xrtSecureZero(AliceShared, sizeof(AliceShared)); xrtSecureZero(BobShared, sizeof(BobShared)); xrtSecureZero(SessionKey, sizeof(SessionKey)); xrtSecureZero(Buffer, sizeof(Buffer)); printf("session round trip: %s\n", bValid ? "valid" : "invalid"); return bValid ? 0 : 1; }
Что только что произошло. Три точки безопасного дизайна, каждая отвечает дисциплине главы. (1) Пары ключей обеих сторон: xrtX25519KeyPair сэмплирует из безопасственного источника — временный приватный ключ каждой стороны никогда её не покидает; общий секрет считается xrtX25519Shared (безопасный вход, стойкий к злой публичной части) и сверяется на согласованность xrtConstTimeEqual. (2) Деривация связывает идентичность: сырой общий секрет напрямую в AEAD не идёт (позитивная демонстрация ловушки 2 главы 76) — salt у HKDF это строка версии протокола, info это Transcript из конкатенации публичных ключей обеих сторон: производный сеансовый ключ одновременно привязан к «идентичности обеих сторон данной negociación»; подмените посредником любой публичный ключ — обе стороны выведут разные ключи, и следующие сообщения взаимно не пройдут проверку (инженерная форма защиты от unknown key-share). (3) Запись связывает номер: AAD это "record:0" — первая запись с номером ноль, далее по нарастанию; повтор старой записи или доставка не по порядку будет отвергнута Open-ом из-за несовпадения AAD. Cleanup-финал проводит пять классов чувствительного материала (оба приватных ключа, оба общих секрета, сеансовый ключ, буфер открытого текста) через xrtSecureZero по одному — это шаблонный код четвёртого отрезка жизненного цикла. Сверьте с главой 81: рукопожатие TLS 1.3 добавляет поверх лишь проверку сертификатов и более полную машину состояний — скелет у вас уже весь.
77.4契约
- Постоянное время: время
xrtConstTimeEqualпропорционально только параметру длины; не скрывает длину, не полная защита от боковых каналов; обязательно для всякого сравнения «одна сторона — секрет». - Безопасное обнуление:
xrtSecureZero— volatile-записи, не удаляется как мёртвое хранилище; покрывает только данный вызовом интервал — регистровые копии компилятора, журналы, файл подкачки, crash-дампы требуют системных мер; объекты с состоянием — через специализированныеClear/KeyClear. - Источник случайности: безопасственное назначение (ключи/соли/nonce/токены) — только
xrtSecureRandom; воспроизводимое назначение (тесты/базисы) — только обычная случайность; криптомодуль зависит от безопасственной через независимую возможностьRANDOM_SECURE. - Ключи не входят в персистентные записи: журналы, сообщения об ошибках, аварийные отчёты, отладочная печать — ноль содержимого ключей; «отладочное удобство» шестнадцатеричного дампа ключа и есть авария утечки.
- Отказ единственный: проверка не прошла — это отказ; без даунгрейда-продолжения; путь ошибки исполняет ту же очистку чувствительного материала, что и путь успеха.
- Привязка контекста: info у KDF, AAD у AEAD, Transcript у рукопожатия — каждый связывает назначение/номер/идентичность; шифртекст неотделим от своего контекста.
- Временные ключи: на каждую сессию новая пара (прямая секретность); долгосрочный ключ только подписывает, не шифрует данные.
- Приоритет комбинирования: готовый протокол > комбинация стандартных примитивов > самодельный протокол; каждый шаг самоделки требует безопасственного ревью.
Типичные ошибки
Симптом: дистанционная тайминг-атака восстанавливает токен/MAC побайтно — время отклика меняется с длиной общего префикса; атакующему хватает нескольких тысяч запросов на байт.
Причина: memcmp выходит досрочно на первом различии. Функциональные тесты совершенно зелёные (результат верен), безопасственный атрибут совершенно отсутствует (утечка тайминга).
uint8 Mac[XRT_SHA256_SIZE]; xrtHmacSha256(Key, sizeof(Key), Body, iSize, Mac); if ( memcmp(Mac, SentMac, sizeof(Mac)) == 0 ) { accept(); /* время-канал: при верном префиксе возврат медленнее */ }
uint8 Mac[XRT_SHA256_SIZE]; xrtHmacSha256(Key, sizeof(Key), Body, iSize, Mac); if ( xrtConstTimeEqual(Mac, SentMac, sizeof(Mac)) ) { accept(); /* время зависит только от длины, без информации о префиксе */ }
Симптом: аудит памяти находит ключи открытым текстом в освобождённой памяти; дизассемблер показывает, что кода обнуления вовсе нет.
Причина: компилятор видит «после memset нет чтений» и удаляет обнуление как мёртвое хранилище — стандарт C это разрешает: значение после memset в абстрактной машине действительно никто не использует.
void handle_key(uint8* pKey, size_t iSize) { use(pKey, iSize); memset(pKey, 0, iSize); /* удалено оптимизацией DSE */ }
void handle_key(uint8* pKey, size_t iSize) { use(pKey, iSize); xrtSecureZero(pKey, iSize); /* volatile-запись, оптимизатор удалить не может */ }
Симптом: платформа сбора журналов (со всеми нисходящими: поиск, резервные копии, сторонняя аналитика) получила продакшен-ключи; аварийный ответ с ротацией ключей занимает всю ночь — журнал неотзываем.
Причина: «глянуть, верный ли ключ» из периода разработки попало в уровень журнала по умолчанию, и при выпуске никто не вспомнил его удалить. Ключ, войдя в журнал-систему, завершил неконтролируемое распространение.
/* "наспех впечатать" при разборе */ log_debug("key=%02x%02x... nonce=%02x%02x...", Key[0], Key[1], Nonce[0], Nonce[1]);
/* 只打印密钥的存在性与长度,永不打印内容 */ log_debug("key ready: %zu bytes, nonce ready: %zu bytes", iKeySize, iNonceSize); 而不是密钥本身 */
Практические задания
Постройте две пары 32-байтовых буферов: одна полностью идентична, другая различается только последним байтом. Прогоните сравнение миллион десять раз каждой пары сначала memcmp, затем xrtConstTimeEqual, хронометрируя четыре группы. Критерий приёмки: memcmp при «первых 31 байтах одинаковы» заметно быстрее, чем при «различие в первом же байте»; две группы xrtConstTimeEqual статистически неразличимы.
Вернитесь к каналу записей из упражнения главы 75 и построчно пометьте позиции генерации, использования и очистки каждого класса чувствительного материала (ключи/nonce/открытый текст); проверьте: не безопасственное ли значение сгенерировано обычной случайностью? нет ли сравнения секретов через memcmp? покрывает ли cleanup путь успеха? Исправьте всё и зафиксируйте правки. Критерий приёмки: чек-лист аудита покрывает все пункты системного слоя трёхслойной модели провала.
Расширьте crypto/session: поддержка произвольного числа записей в обе стороны — 64-битный номер записи, nonce заполняется номером (счётчикный метод), AAD связывает «направление + номер» (у каждого направления свой независимый счётчик); после последнего сообщения обе стороны активно уничтожают сессию (чистка ключей и печать session destroyed). Атакующий опыт: перехватите третью запись и повторите — Open обязан отказать; доставка не по порядку — обязана отказать. Критерии приёмки: по десять записей в каждую сторону проходят круглую поездку; весь чувствительный материал обнулён в сегменте уничтожения (контроль статистикой главы 6).
Итоги
| Тема | Кратко |
|---|---|
| Три слоя провала | алгоритмы (труднее всего сломать) < протоколы < система (ломаются чаще всего) — внимание наоборот |
| Постоянное время | xrtConstTimeEqual зависит только от длины; обязателен для сравнения секретов; длину не скрывает, не все боковые каналы |
| Безопасное обнуление | xrtSecureZero volatile-записи против DSE; покрывает лишь данный интервал; объекты состояния — Clear/KeyClear |
| Граница случайности | безопасственные значения (ключи/соли/nonce/токены) → xrtSecureRandom; воспроизводимые → обычная |
| Жизненный цикл ключа | генерация (законный источник) → использование (минимальное пребывание) → хранение (не в журналы/дампы) → уничтожение (SecureZero) |
| Отказ единственный | проверка не прошла — отказ; без даунгрейда; путь ошибки чистит так же |
| Привязка контекста | info KDF — назначение / AAD — номер и версия / Transcript — идентичность |
| Временные ключи | новая пара на сессию; прямая секретность; долгосрочный только подписывает |
| Приоритет комбинирования | готовый протокол > стандартная комбинация > самоделка; образец session — шаблон стандартной комбинации |
| Скелет сессии | KeyPair → Shared → HKDF(Transcript) → AEAD(AAD=номер) → SecureZero всё |
📖 Справочники API в этой главе:crypto (140 записей)random (41 записей)
Полные сигнатуры, ограничения параметров, возвращаемые значения и ошибки