TPM научили подписывать TLS-рукопожатия, приватный ключ больше не лежит в файле

Бруно Схатсберген написал библиотеку go-tpm-tls для языка Go, которая подписывает TLS-рукопожатия приватным ключом, никогда не покидающим доверенный модуль TPM, вместо привычного client.key, который приложение читает в память при старте. Повод, работа автора над удалённой аттестацией конфиденциальных виртуальных машин: после того как машина доказала свою честность, ей всё равно нужна идентичность, которой она подтверждает себя другим сервисам через mTLS (взаимную TLS-аутентификацию).

Проблема, которую решает библиотека: файл приватного ключа, это ключ-предъявитель (bearer credential). Как только процесс прочитал его в память, ключ оказывается в дампе кучи, core-файле, странице подкачки и слепке памяти виртуальной машины, снятом гипервизором, и доступен всему, что получило выполнение кода в этом процессе. Скопировавший ключ становится той же машиной везде, где ей доверяют: в хранилище секретов, во внутреннем API, принимающем только клиентские сертификаты, и в базе, связывающей субъект сертификата с ролью. Автор прямо сужает модель угроз: он защищается от того, кто получил доступ на чтение внутри гостевой системы (чтение файла, core-дамп, SSRF, утёкший бэкап), а не от того, кто уже получил постоянное выполнение кода на машине и использует идентичность, пока сам ещё на ней, это другая задача, и TPM её не решает.

Из трёх альтернатив, жёсткие права на файл, короткоживущие сертификаты, облачный KMS (Key Management Service, сервис управления ключами), каждая упускает хотя бы одно из четырёх нужных свойств: ключ должен подписывать TLS-рукопожатия, не должен покидать машину, не нужен второй секрет для доступа к нему, он должен работать с обычным стеком TLS. KMS ближе всего, но требует credential для аутентификации перед сервисом, а этот credential сам становится новой целью для кражи, плюс каждое холодное рукопожатие зависит от сетевого похода к внешнему сервису. TPM, единственный вариант, где всё это не нужно, при этом автор прямо оговаривает: TPM не более безопасен, чем хорошо настроенный KMS, его преимущество, локальность и то, что он уже стоит почти в любой машине последнего десятилетия.

Технически всё держится на том, что интерфейс crypto.Signer в Go просит у ключа только 'подпиши этот диготест' (digest, хеш-свёртка), а именно это умеет команда TPM2_Sign. Библиотека находит нужный TPM-ключ не по хендлу (адресу объекта в TPM), а по публичному ключу, зашитому в уже имеющийся у приложения сертификат, поэтому конфигурация не завязана на конкретную машину. go-tpm-tls намеренно не умеет создавать или удалять ключи в TPM: она только присоединяется к ключу, который заранее создал отдельный процесс подготовки (обычно агент аттестации). Требования к ключу: он должен быть нерестриктивным (restricted-ключ, то есть ключ аттестации, подписывает только то, что сам TPM хешировал, и откажет с ошибкой TPM_RC_TICKET), эллиптическим (P-256 или P-384, RSA-ключи технически загружаются, но не так, как их просит crypto/tls), без пароля/политики доступа и сидеть на постоянном (persistent), а не временном (transient) хендле.

Автор сам замерил цену такого подхода на конфиденциальных виртуальных машинах Google Cloud (n2d-standard-2 с AMD SEV-SNP, кривая P-256, один поток). Одна подпись постоянным TPM-ключом, медиана 2,21 мс при 452 подписях в секунду, против 0,06 мс и 16591 подписи в секунду у программного ключа: TPM медленнее в разы. Временный (не постоянный) TPM-ключ ещё дороже, 20,44 мс на подпись, то есть примерно в десять раз дороже постоянного (на Intel TDX разрыв доходит до пятнадцати раз), из-за того, что диспетчер ресурсов ядра пересохраняет контекст временного объекта при каждой команде. Полное новое TLS-соединение с TPM-ключом занимает 3,35 мс (295 соединений в секунду) против 1,38 мс у программного ключа (707 соединений в секунду), то есть сама подпись (2,21 из 3,35 мс) съедает большую часть времени холодного рукопожатия. Когда включено переиспользование TLS-сессии (одно полное рукопожатие плюс 49 переиспользованных из 50 соединений), разрыв почти исчезает: 0,94 мс у TPM против 0,95 мс у софтверного ключа, потому что подписей там уже не 50, а одна. На той же логике держится и сценарий с одним переиспользуемым соединением на 50 запросов, там TPM и софт почти неотличимы (0,02 мс против 0,02 мс), потому что подпись всего одна.

Автор нашёл два неожиданных эффекта. Во-первых, замедление временного ключа, это свойство файлового дескриптора, а не самого ключа: постоянный ключ, к которому больше никто не обращается, подписывает за 2,08 мс, но тот же самый ключ подписывает за 20,04 мс, если на том же дескрипторе параллельно загружен посторонний временный объект, и снова быстро (2,10 мс) на отдельном дескрипторе. Во-вторых, выбор кривой почти не влияет на цену: P-384 на этой же машине подписывает за 2,37 мс против 2,21 мс у P-256 (226 холодных рукопожатий в секунду против 295), но сам автор предупреждает не доверять этой цифре до второго знака, на более раннем прогоне на том же типе инстанса разница составила 16%, а не 7%, то есть измерение шумное; на Intel TDX разница между кривыми доходила до 65%.

TPM-подпись доказывает две вещи: что соединение исходит именно от машины, чей TPM держит ключ, и что рукопожатие происходит сейчас, а не является повтором старого (за счёт свежей случайности в подписываемой структуре). Она не доказывает, что код, работающий на этой машине, заслуживает доверия, и не защищает сам трафик соединения, сессионные ключи живут в обычной памяти процесса. Плюс достижимость ключа зависит от того, как реализован виртуальный TPM: на измеренной машине (Google Cloud, AMD SEV-SNP) виртуальный TPM реализован программно на стороне гипервизора, то есть недоступен гостевой системе, но доступен платформе; у альтернативной схемы, паравизор (например, открытый COCONUT-SVSM), виртуальный TPM работает внутри зашифрованного гостя, и платформа его прочитать не может. В планах автора, связать TPM-ключ с результатом аттестации через стандарт SPIFFE (Secure Production Identity Framework For Everyone): публичный ключ TPM уходит вместе с доказательством аттестации, а обратно приходит короткоживущий сертификат X.509-SVID над тем же ключом; работа над этой частью, по словам автора, ещё не завершена.

Ключевые факты

  • Библиотека go-tpm-tls для Go подписывает TLS-рукопожатия ключом, который физически не покидает чип TPM, вместо файла client.key, уязвимого для дампов памяти, core-файлов и снимков гипервизора.
  • На Google Cloud (n2d-standard-2, AMD SEV-SNP, P-256) постоянный TPM-ключ подписывает за 2,21 мс (452 подписи/с) против 0,06 мс (16591 подписи/с) у программного ключа; временный TPM-ключ ещё дороже, 20,44 мс, то есть примерно в 10 раз медленнее постоянного.
  • При переиспользовании TLS-сессии разница почти исчезает (0,94 мс против 0,95 мс), потому что подпись требуется только на одном рукопожатии из пятидесяти, а не на каждом соединении.
  • TPM защищает только от чтения памяти/диска, но не от атакующего, уже выполняющего код на машине, и не от достижимости ключа хостом, это зависит от реализации виртуального TPM (гипервизор видит ключ; паравизор вроде COCONUT-SVSM, нет).
  • Из-за того что TPM подписывает по одной команде за раз без тайм-аута на блокировке, машина упирается в потолок в несколько сотен новых mTLS-соединений в секунду, и это же превращает TPM-ключ на публичном слушателе в удобную цель для DoS.

Почему это важно

Обычная схема mTLS хранит приватный ключ клиента в файле, который процесс читает в память при старте, и с этого момента ключ доступен любому, кто получил доступ на чтение к машине: через дамп кучи, core-файл, страницу подкачки или слепок памяти виртуалки, снятый гипервизором. Скопировавший такой файл становится той же машиной везде, где ей доверяют. go-tpm-tls убирает именно эту точку отказа: ключ создаётся и живёт внутри TPM, наружу через интерфейс Go crypto.Signer выходит только подпись над хешем, а не сам ключевой материал, при этом код приложения не меняется, тот же tls.Config, что и для ключа в файле.

Кому это важно

В первую очередь, инженерам, которые строят внутреннюю mTLS-аутентификацию между сервисами, особенно поверх конфиденциальных виртуальных машин с удалённой аттестацией: аттестация подтверждает состояние машины один раз, а идентичность для последующих соединений нужна постоянно. Также полезно всем, кто проектирует машинную идентичность как альтернативу или дополнение к облачному KMS, и разработчикам на Go, которым нужен прозрачный crypto.Signer поверх TPM, а не самописный протокол поверх обычного TLS.

Как это применить

go-tpm-tls только присоединяется к уже созданному ключу, ключ в TPM должен заранее подготовить отдельный процесс, обычно агент аттестации; сама библиотека намеренно не умеет создавать или удалять ключи. Ключ находится по публичному ключу из сертификата приложения, а не по хендлу (адресу объекта в TPM), поэтому конфигурация переносится между машинами. Требования к ключу: эллиптическая кривая P-256 или P-384, отсутствие ограничения restricted (иначе TPM откажется подписывать транскрипт рукопожатия), отсутствие пароля/политики доступа, постоянный (persistent), а не временный хендл, временный обходится примерно в 10 раз дороже за подпись. Открывать нужно /dev/tpmrm0 (диспетчер ресурсов ядра), а не /dev/tpm0, и это требует root или членства в группе tss. Автор отдельно предупреждает: TPM-ключ на публичном слушателе, плохая идея из-за потолка пропускной способности подписи; такой ключ уместен на исходящем клиенте или на внутреннем сервисе с ограничением частоты рукопожатий.

Можно ли доверять

Это рабочие заметки автора о собственном опубликованном open-source коде (go-tpm-tls) и собственном бенчмарке (go-tpm-tls-bench), с подробной методикой: конкретные типы виртуальных машин Google Cloud (n2d-standard-2 с AMD SEV-SNP, c3-standard-4 с Intel TDX), число прогонов, конкретные команды tpm2-tools для проверки. Автор сам честно отмечает пределы своих цифр: они получены только на виртуальных TPM, реализованных гипервизорами Google Cloud, и ничего не говорят о дискретном TPM-чипе; разница между кривыми P-256 и P-384 признана шумной (16% на одном прогоне против 7% на другом на том же типе машины). Такая саморефлексия по поводу методики, плюс к доверию, но цифры остаются однократным измерением на двух конкретных облачных инстансах, а не воспроизведённым независимо исследованием.

Риски и подводные камни

TPM подписывает команды по одной за раз, блокировка без тайм-аута, машина упирается в потолок в несколько сотен новых mTLS-соединений в секунду, и это одновременно удобный вектор отказа в обслуживании: злоумышленник, форсирующий полные рукопожатия против публичного TPM-слушателя, дёшево выжигает эту пропускную способность и ставит в очередь все остальные рукопожатия процесса. TPM не защищает от атакующего, который уже получил постоянное выполнение кода на машине, тот продолжит подписывать, пока сидит внутри, и не защищает сам трафик соединения, поскольку сессионные ключи существуют в обычной памяти отдельно от TPM. Наконец, достижимость ключа для хост-платформы зависит от того, как реализован виртуальный TPM: на гипервизорной реализации (как на измеренной машине Google Cloud) хост технически может дотянуться до состояния TPM, тогда как схема с паравизором вроде COCONUT-SVSM прячет виртуальный TPM внутри зашифрованного гостя от самого хоста, и это разница, которую нужно выяснить для конкретной платформы, прежде чем описывать это свойство кому-либо ещё.

«Скопируй ключ, и ты и есть та машина, везде, где ей доверяют: в хранилище секретов, во внутреннем API, который принимает только клиентские сертификаты, и в базе, которая связывает субъект сертификата с ролью.»

— Бруно Схатсберген, автор go-tpm-tls