Перейти к содержимому

Инструменты выпуска (tessera_issuer)

Tessera проверяет сертификаты на устройстве, но сами сертификаты нужно чем-то выпускать. Инструменты выпуска закрывают эту сторону: одно Rust-ядро собирает TBSCertificate с расширениями Tessera, проверяет монотонное сужение рамок делегирования до подписи и подписывает результат ключом выбранного бэкенда — токена/HSM (PKCS#11), Vault Transit или локального PKCS#8-файла. С бэкендами PKCS#11 и Vault инструмент — не кастодиан: приватные ключи через код выпуска не проходят; файловый бэкенд — осознанный компромисс, ключ живёт в памяти процесса выпуска (см. threat-model.md §11).

Компоненты:

  • Ядро (tessera_issuer, библиотека) — сборка TBS листа смены и CA организации, проверки рамок, случайные 128-битные серийники, выпуск CRL, журнал выпусков. Ядро pure-Rust и собирается в том числе под wasm32; адаптеры подписи — за feature-флагами.
  • CLI issuer — код выпуска для автоматизации (тикет-системы, скрипты) и ручной работы. Ни одна проверка в CLI не переопределяется: запрос, который отверг бы ядро, CLI отвергает точно так же.

Выпуск из браузера — локальный агент подписи и веб-кабинет, работающие с тем же ядром, — поставляется отдельно, в составе коммерческих инструментов (см. «Выпуск из браузера»). Открытый репозиторий поставляет CLI.

Модель ролей — из предъявленного родительского сертификата: любой CA с рамками делегирования (корень парка или CA организации) выпускает и подчинённые CA организаций, и листы смен инженерам — строго в своих рамках делегирования, которые на каждом шаге могут только сужаться. Отдельного «режима по должности» нет.

Семантику самих расширений (host_binding, allowed_roles, max_integrity, profile_version, delegation_constraints) и их OID см. в cert-issuance.md — здесь описан инструмент, а не формат. Поверхность атаки инструментов выпуска разобрана в threat-model.md §11; дублировать её тут не будем.

Все выпускающие подкоманды выбирают бэкенд подписи флагами --backend (pkcs11 — по умолчанию, vault, file), --key (метка ключа CA; для file — опционален, по умолчанию имя файла ключа), --algorithm (ecdsa-p256 — по умолчанию, ecdsa-p384, rsa-sha256; для file алгоритм выводится из самого ключа, а флаг работает как сверка). Времена (--not-before, --not-after, --this-update, …) задаются в Unix-секундах. Вход (--parent, --spki, --csr, --issuer) принимается в PEM или DER — формат определяется по содержимому. Выход — PEM, либо DER при --der.

PIN токена никогда не передаётся аргументом командной строки: PKCS#11-бэкенд запрашивает его через pinentry на время операции, а при отсутствии pinentry — из переменной TESSERA_ISSUER_PIN (см. Бэкенды подписи).

Под корневым сертификатом парка выпускается CA организации с назначением рамок делегирования (роли, потолок уровня МКЦ, потолок TTL, требуемые метки):

Окно терминала
issuer issue-ca \
--backend pkcs11 --module /usr/lib/x86_64-linux-gnu/opensc-pkcs11.so \
--key tessera-root --algorithm ecdsa-p256 \
--parent root.pem \
--spki org-ca.spki.der \
--subject "CN=Org North CA,O=Org" \
--not-before 1750000000 --not-after 1900000000 \
--allow-role oper --allow-role serv \
--max-level 5 --max-ttl 14400 \
--require-tag region=north \
--journal issuance.ndjson \
--out org-ca.pem

Флаги --allow-role, --require-tag повторяются для нескольких значений. Рамки выпускаемого CA обязаны быть ⊆ рамок родителя — иначе ядро отказывает до подписи с указанием измерения (см. монотонное сужение в cert-issuance.md).

--allow-role обязателен и у issue-ca, и у issue-root: список ролей в рамках делегирования — это закрытый белый список, и пустой список разрешает не «любую роль», а ни одной. Умолчания здесь быть не может: имена ролей принадлежат конкретному внедрению, поэтому любое подставленное значение либо повторяет тот же тупик, либо молча расширяет рамки сверх названного оператором.

--max-ttl ограничивает срок жизни дочернего звена, поэтому у двух операций разный смысл и разные умолчания:

ОперацияЧто ограничивает --max-ttlУмолчание
issue-rootсрок CA организации под корнем парка31536000 (год)
issue-caсрок листа смены под CA организации14400 (4 часа)

Явный --max-ttl 0 отвергается при разборе аргументов: нулевой потолок требует от дочернего звена нулевого срока действия, то есть под таким CA не проходит ни один выпускаемый сертификат.

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

Публичный ключ листа берётся из явного --spki (тогда --subject обязателен) или из --csr (тогда субъект и ключ берутся из запроса). Флаги взаимно исключающие.

Прямой путь (SPKI):

Окно терминала
issuer issue-leaf \
--backend pkcs11 --module /usr/lib/.../opensc-pkcs11.so \
--key org-north-ca \
--parent org-ca.pem \
--spki ivanov.spki.der \
--subject "CN=ivanov,O=Org" \
--host "sha256:<host_id_hash>" \
--role oper \
--not-before 1750000000 --not-after 1750086400 \
--max-integrity-level 2 --max-integrity-categories 0x1 \
--journal issuance.ndjson \
--out ivanov.pem

Путь по CSR (см. CSR-поток):

Окно терминала
issuer issue-leaf \
--backend pkcs11 --module /usr/lib/.../opensc-pkcs11.so \
--key org-north-ca \
--parent org-ca.pem \
--csr ivanov.csr.pem \
--host "sha256:<host_id_hash>" --role oper \
--not-before 1750000000 --not-after 1750086400 \
--journal issuance.ndjson \
--out ivanov.pem

--host и --role повторяются. --max-integrity-level опционален (без него потолок целостности не задаётся); --max-integrity-categories (битовая маска) учитывается только вместе с уровнем.

Допуск задают два расширения выпускаемого листа, и оба собираются из этих флагов: pam_cert_host_binding (--host) — на каких устройствах удостоверение принимается, pam_cert_allowed_roles (--role) — какие роли предъявитель может активировать. Имя учётной записи входа и есть роль, поэтому второй список одновременно определяет, в какие ролевые учётные записи пущен предъявитель: отдельного списка допуска по учётным записям нет.

Окно терминала
issuer issue-crl \
--backend pkcs11 --module /usr/lib/.../opensc-pkcs11.so \
--key org-north-ca \
--issuer org-ca.pem \
--this-update 1750000000 --next-update 1750604800 \
--crl-number 7 --last-crl-number 6 \
--revoke 2a:1750000500:1 \
--revoke 3b:1750000600 \
--journal issuance.ndjson \
--out org-ca.crl

--crl-number обязан быть строго больше --last-crl-number (монотонность crlNumber в стейте CA) — иначе отказ. Каждый --revoke — это serial_hex:unix_date[:reason_code], где reason_code — код причины RFC 5280 (0–6), опционален; флаг повторяется.

Окно терминала
issuer verify-journal --journal issuance.ndjson

Печатает одно из трёх состояний: цепочка цела и полностью подписана; цела, но хвост не подписан (с номером seq, с которого); нарушена (с позицией первой невалидной записи — тогда ненулевой код возврата). См. Журнал выпусков.

Результатные сообщения оператору локализованы (RU/EN). Локаль: флаг --lang (ru/en) → TESSERA_ISSUER_LANGLANG → английский по умолчанию. Совпадение по префиксу: любое значение, начинающееся на ru, выбирает русский. Технические идентификаторы (субъект RFC 4514, OID, crlNumber, серийники) не переводятся. См. Локализация.

CSR (PKCS#10) — равноправный с прямым SPKI источник ключа листа. Он снимает необходимость передавать инструменту публичный ключ отдельно и даёт proof-of-possession: инженер генерирует ключ на своём токене и подписывает им запрос.

Сторона инженера — сформировать CSR ключом на токене:

Окно терминала
issuer csr \
--backend pkcs11 --module /usr/lib/.../opensc-pkcs11.so \
--key ivanov-token-key --algorithm ecdsa-p256 \
--subject "CN=ivanov,O=Org" \
--spki ivanov.spki.der \
--out ivanov.csr.pem

Инструмент только подписывает: публичный ключ инженера (--spki) подаётся явно, запрос подписывается тем ключом токена, что адресует --key. Proof-of-possession действителен, только если ключ токена соответствует --spki — это ответственность инженера, ключи инструмент не генерирует.

Сторона оператора — issue-leaf --csr (см. выше). Что важно:

  • Ядро проверяет самоподпись CSR (P-256/RSA, pure-Rust) до выпуска; битая самоподпись → отказ до подписи. CLI дополнительно печатает субъект CSR и статус самоподписи перед выпуском.
  • Субъект и публичный ключ берутся из CSR. Скоуп (рамки, привязки, роли) задаёт исключительно оператор флагами — атрибуты CSR на состав расширений не влияют. Иначе CSR стал бы каналом «инженер сам запросил себе шире».

Выпуск из браузера — локальный агент подписи на 127.0.0.1, который мостит браузер к токену/HSM, плюс веб-кабинет (SPA поверх того же WASM-ядра), который собирает TBS на клиенте и показывает оператору сводку для подтверждения, — поставляется отдельно, в составе коммерческих инструментов (tessera-enterprise). Из этого репозитория он не собирается. Контакт — см. LICENSE.commercial.

Открытый репозиторий поставляет CLI issuer, который работает с тем же ядром и теми же бэкендами подписи и выполняет те же проверки до подписи. Всё ниже — бэкенды подписи и журнал выпусков — относится к CLI.

Ядро не знает, где ключ: подпись готового TBS уходит за единый интерфейс, никакой ключевой материал через него не проходит. Бэкенд выбирается --backend.

Бэкенд по умолчанию, один код для аппаратных токенов и HSM. Флаги: --module (путь к .so/.dylib/.dll — обязателен), --token-label (выбор токена, если их несколько), --key (метка CKA_LABEL ключа CA), --pinentry (программа pinentry явно).

PIN запрашивается через pinentry на время операции (Secret + zeroize, не в логах и не в argv); при отсутствии pinentry — из TESSERA_ISSUER_PIN.

Для проб и CI подойдёт SoftHSM как программный PKCS#11-модуль. ГОСТ-токены работают через тот же адаптер, если токен отдаёт нужный PKCS#11-механизм.

Подпись готового TBS через HTTP-API Transit. Флаги: --vault-addr (например https://vault.example:8200 — обязателен), --mount (mount Transit, по умолчанию transit), --vault-key (имя ключа Transit; по умолчанию равно --key), --ca-bundle (PEM-бандл доверенных CA вместо системного стора — для приватных Vault-CA), --prehashed (слать локально вычисленный дайджест с prehashed=true — для ключей, настроенных на pre-hashed вход).

Токен Vault читается из переменной окружения VAULT_TOKEN (пустой/незаданный → отказ), передаётся в заголовке X-Vault-Token и не логируется. Для ECDSA адаптер запрашивает marshaling_algorithm=asn1 (Vault возвращает DER-подпись).

Только Transit, не Vault PKI. Движок Vault PKI непригоден для Tessera: encoding/asn1 в Go не разбирает OID-дуги больше int64, а наши расширения сидят в арке 2.25.<UUID> — через Vault PKI такой сертификат не выпустить. Поэтому Transit подписывает уже собранный нами TBS, а не строит сертификат.

Transit не проверяет, что подписывает, — все проверки выпуска выполняются до подписи в ядре, на клиенте; кто может звать sign, ограничивает политика Vault.

Бэкенд file подписывает ключом CA из локального файла: --key-file <path>. Формат — PKCS#8 (PEM или DER), включая зашифрованный (ENCRYPTED PRIVATE KEY, PBES2); типы ключей — ECDSA P-256/P-384 и RSA. ГОСТ-ключи файловым бэкендом не поддерживаются — для ГОСТ остаётся PKCS#11. Другие форматы конвертируются штатно:

Окно терминала
# новый зашифрованный ключ P-256
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
| openssl pkcs8 -topk8 -v2 aes-256-cbc -out ca-key.p8
chmod 600 ca-key.p8
# конвертация существующего ключа (SEC1/PKCS#1 → PKCS#8)
openssl pkcs8 -topk8 -v2 aes-256-cbc -in old-key.pem -out ca-key.p8

Правила бэкенда:

  • Файл ключа обязан быть недоступен группе и остальным (chmod 600) — иначе отказ до чтения содержимого. Владение файлом и права каталога бэкенд не проверяет — держите ключ в своём каталоге с правами 700.
  • Пароль зашифрованного ключа запрашивается через pinentry, при его отсутствии берётся из TESSERA_ISSUER_KEY_PASSPHRASE; в аргументы командной строки и логи пароль не попадает, память затирается.
  • Незашифрованный ключ принимается, но с предупреждением при каждом старте; рекомендация — зашифрованный PKCS#8.
  • Алгоритм подписи выводится из самого ключа; --algorithm, не совпадающий с ключом, — ошибка. --key опционален (по умолчанию — имя файла) и служит идентификатором ключа в журнале выпусков.

Ключ в файле — осознанный компромисс для стендов, CI и малых инсталляций: при компрометации хоста он, в отличие от токена/HSM/Vault, извлекаем. Для прода рекомендованы PKCS#11 или Vault Transit (см. threat-model.md §11).

Каждая операция (выпуск листа, CA, CRL) — запись в NDJSON-журнале, связанная в hash-chain: монотонный seq, хэш предыдущей записи, фиксированный genesis. Журнал fail-closed: запись делается до выдачи артефакта, и если журнал недоступен, операция отклоняется (сертификат без записи не выпускается). Путь задаётся флагом --journal каждой выпускающей подкоманды.

Голова цепочки периодически подписывается через тот же интерфейс подписи (по завершении сессии и по команде). issuer verify-journal различает три состояния:

  • цела, хвост полностью подписан — всё в порядке;
  • цела, неподписанный хвост с seq N — цепочка не нарушена, но записи с N ещё не покрыты подписью головы;
  • нарушена в позиции N — разрыв/подмена/переупорядочивание на записи N (ненулевой код возврата).

Журнал вторичен: первичная правда — аудит входов на самих устройствах; журнал служит инвентаризации выпуска и разбору инцидентов.

Операторские поверхности инструмента (сводка операции, построенная из TBS, и вывод CLI) локализованы на русский и английский без i18n-фреймворка (компактная таблица строк). Для CLI локаль разрешается один раз при старте:

  1. явная настройка — флаг --lang (ru/en);
  2. переменная TESSERA_ISSUER_LANG;
  3. переменная LANG;
  4. fallback — английский.

Совпадение по префиксу языка, регистронезависимо: ru_RU.UTF-8 и RU дают русский, en_GB — английский; нераспознанное значение просто проваливается к следующему источнику. Переводятся только подписи полей — технические данные (субъект RFC 4514, OID, role_id, серийники, crlNumber, таймстемпы) воспроизводятся байт-в-байт в любой локали.

  • cert-issuance.md — расширения Tessera, их OID и семантика, монотонное сужение рамок делегирования.
  • threat-model.md §11 — поверхность атаки инструментов выпуска, ограничение ущерба, остаточные риски.